mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +02:00
Compare commits
383 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 |
9
.github/actions/prepare-keystore/action.yml
vendored
9
.github/actions/prepare-keystore/action.yml
vendored
@ -19,16 +19,13 @@ runs:
|
|||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Write Keystore file 🗄️
|
- name: Write Keystore file 🗄️
|
||||||
id: android_keystore
|
shell: bash
|
||||||
uses: timheuer/base64-to-file@v1.0.3
|
run: echo "${{ inputs.keyStoreBase64 }}" | base64 -d > /home/runner/key.jks
|
||||||
with:
|
|
||||||
fileName: key.jks
|
|
||||||
encodedString: ${{ inputs.keyStoreBase64 }}
|
|
||||||
|
|
||||||
- name: Write Keystore properties 🗝️
|
- name: Write Keystore properties 🗝️
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties
|
echo "storeFile=/home/runner/key.jks" > key.properties
|
||||||
echo "storePassword=${{ inputs.signingStorePassword }}" >> key.properties
|
echo "storePassword=${{ inputs.signingStorePassword }}" >> key.properties
|
||||||
echo "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
|
echo "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
|
||||||
echo "keyAlias=${{ inputs.signingKeyAlias }}" >> key.properties
|
echo "keyAlias=${{ inputs.signingKeyAlias }}" >> key.properties
|
||||||
|
11
.github/workflows/build-testing.yaml
vendored
11
.github/workflows/build-testing.yaml
vendored
@ -7,15 +7,15 @@ jobs:
|
|||||||
debug-builds:
|
debug-builds:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: gradle/wrapper-validation-action@v1
|
- uses: gradle/wrapper-validation-action@v2
|
||||||
|
|
||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: "adopt"
|
distribution: "adopt"
|
||||||
java-version: 19
|
java-version: 21
|
||||||
cache: "gradle"
|
cache: "gradle"
|
||||||
|
|
||||||
- name: Compile
|
- name: Compile
|
||||||
@ -23,6 +23,7 @@ jobs:
|
|||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
name: alibi-app-debug-apks
|
||||||
path: app/build/outputs/apk/debug/app-*-debug.apk
|
path: app/build/outputs/apk/debug/app-*-debug.apk
|
||||||
|
19
.github/workflows/release-app-github.yaml
vendored
19
.github/workflows/release-app-github.yaml
vendored
@ -10,7 +10,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: gradle/wrapper-validation-action@v2
|
||||||
|
|
||||||
- name: Write KeyStore 🗝️
|
- name: Write KeyStore 🗝️
|
||||||
uses: ./.github/actions/prepare-keystore
|
uses: ./.github/actions/prepare-keystore
|
||||||
@ -21,10 +23,10 @@ jobs:
|
|||||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
java-version: "17.x"
|
java-version: 21
|
||||||
cache: 'gradle'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Build APKs 📱
|
- name: Build APKs 📱
|
||||||
@ -37,3 +39,14 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: app/build/outputs/apk/release/*.apk
|
files: app/build/outputs/apk/release/*.apk
|
||||||
|
|
||||||
|
- name: Build AABs 📱
|
||||||
|
run: ./gradlew bundleRelease
|
||||||
|
|
||||||
|
- name: Upload APKs bundles 🚀
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
|
with:
|
||||||
|
files: app/build/outputs/bundle/release/*.aab
|
||||||
|
10
.github/workflows/release-app-google-play.yaml
vendored
10
.github/workflows/release-app-google-play.yaml
vendored
@ -10,7 +10,9 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: gradle/wrapper-validation-action@v2
|
||||||
|
|
||||||
- name: Write KeyStore 🗝️
|
- name: Write KeyStore 🗝️
|
||||||
uses: ./.github/actions/prepare-keystore
|
uses: ./.github/actions/prepare-keystore
|
||||||
@ -21,10 +23,10 @@ jobs:
|
|||||||
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
keyStoreBase64: ${{ secrets.KEYSTORE }}
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v3
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'adopt'
|
distribution: 'adopt'
|
||||||
java-version: "17.x"
|
java-version: 21
|
||||||
cache: 'gradle'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Build APKs 📱
|
- name: Build APKs 📱
|
||||||
@ -39,4 +41,4 @@ jobs:
|
|||||||
track: production
|
track: production
|
||||||
status: inProgress
|
status: inProgress
|
||||||
inAppUpdatePriority: 2
|
inAppUpdatePriority: 2
|
||||||
userFraction: 0.33
|
userFraction: 0.2
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
*.iml
|
*.iml
|
||||||
.gradle
|
.gradle
|
||||||
/local.properties
|
/local.properties
|
||||||
|
/.idea
|
||||||
/.idea/caches
|
/.idea/caches
|
||||||
/.idea/libraries
|
/.idea/libraries
|
||||||
/.idea/modules.xml
|
/.idea/modules.xml
|
||||||
|
43
README.md
43
README.md
@ -3,18 +3,21 @@
|
|||||||
# Alibi
|
# Alibi
|
||||||
|
|
||||||
<p float="left" align="center">
|
<p float="left" align="center">
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width="24%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.webp" width="30%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width="24%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.webp" width="30%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width="24%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.webp" width="30%" />
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width="24%" />
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.webp" width="30%" />
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.webp" width="30%" />
|
||||||
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.webp" width="30%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Alibi keeps recording in the background and saves the last 30 minutes at your request.
|
Alibi keeps recording audio/video in the background and saves the last 30 minutes at your request.
|
||||||
Everything is completely configurable. No internet connection required.
|
Everything is completely configurable. No internet connection required.
|
||||||
|
|
||||||
# Download
|
# Download
|
||||||
|
|
||||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/app.myzel394.alibi)
|
[<img src="readme_content/google-play-badge.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=app.myzel394.alibi)
|
||||||
|
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/app.myzel394.alibi)
|
||||||
[<img src="readme_content/github-badge.webp" alt="Get it on GitHub" height="80">](https://github.com/Myzel394/Alibi/releases)
|
[<img src="readme_content/github-badge.webp" alt="Get it on GitHub" height="80">](https://github.com/Myzel394/Alibi/releases)
|
||||||
|
|
||||||
# Supporting Alibi
|
# Supporting Alibi
|
||||||
@ -32,30 +35,8 @@ people can use it more easily.
|
|||||||
|
|
||||||
## Donate
|
## Donate
|
||||||
|
|
||||||
It might sound crazy, but if you would just donate 1$, it would totally mean to world to me, since
|
It might sound crazy, but if you would just donate $ 1, it would totally mean the world to me, since
|
||||||
it's a really small amount and if everyone did that, I can totally focus on Alibi and my other open
|
it's a really small amount and if everyone did that, I could focus on Alibi and my other open
|
||||||
source projects. :)
|
source projects. :)
|
||||||
|
|
||||||
You can donate via:
|
You can donate via [GitHub Sponsors](https://github.com/sponsors/Myzel394) or via [crypto currencies](https://github.com/Myzel394/contact-me?tab=readme-ov-file#donations).
|
||||||
|
|
||||||
* [GitHub Sponsors](https://github.com/sponsors/Myzel394)
|
|
||||||
* Bitcoin: `bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6`
|
|
||||||
* Bitcoin Cash: `qr9s64vfqedvurfef9ykf7szchmt0xyvnga452fc8l`
|
|
||||||
* Ethereum: `0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7`
|
|
||||||
* Tether USD: `0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7`
|
|
||||||
* Monero: `83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8`
|
|
||||||
* Zcash: `t1ZfvNpzfdaW6csT9Kc7iJA7LUU3hmNj2sx`
|
|
||||||
* Litecoin: `LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN`
|
|
||||||
* Dash: `XcTkni8CVAXBcuc5VwvHmsYftVK4CPLetU`
|
|
||||||
* Avalanche: `0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7`
|
|
||||||
* XRP: `rNpfDm8UwDTumCebchBadjVW2FEPteFgNg`
|
|
||||||
* Solana: `2h6CB3hz5Vb2nYS1RQiXZ4aWTzc5frBPR7Sp1b4muFqb`
|
|
||||||
* ADA: `addr1q8vy2vcp6lacaw8lkc29gufuzajaytc5qc0c2mxlmw5lndxcg5esr4lm36u0lds523cnc9m96gh3gpsls4kdlkaflx6qf6qpvc`
|
|
||||||
* Dogecoin: `DUA4j7mVoc7Rvezu8YgeRKwxNuMzKeDoxD`
|
|
||||||
* Tron: `THWVLGhne5wDsGjd1CNenHDKQGzvGzrzLb`
|
|
||||||
* Polkadot: `1642iaR6AoKyM6qnnMHkfCRfRqRKJ2wC6Cm3UEWEFEz6EtZR`
|
|
||||||
* Cosmos: `cosmos1vt5z6rfj5sgnkdlddkuu8srw3xupyqxscva9hz`
|
|
||||||
* Algorand: `QBOQ6VSLMD77QEF33P5J3HKGOM5RZLNO6P5P3FTWCMQM3ORF6QY2W34KUI`
|
|
||||||
* Tezos: `tz1QUWNYuFqDibGCrwmkdaHSpTx3d6ZdxLMi`
|
|
||||||
* Litecoin: `LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN`
|
|
||||||
* Filecoin: `f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq`
|
|
||||||
|
@ -35,8 +35,8 @@ android {
|
|||||||
applicationId "app.myzel394.alibi"
|
applicationId "app.myzel394.alibi"
|
||||||
minSdk 24
|
minSdk 24
|
||||||
targetSdk 34
|
targetSdk 34
|
||||||
versionCode 7
|
versionCode 16
|
||||||
versionName "0.3.0"
|
versionName "0.5.3"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -79,9 +79,10 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose true
|
compose true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
|
viewBinding = true
|
||||||
}
|
}
|
||||||
composeOptions {
|
composeOptions {
|
||||||
kotlinCompilerExtensionVersion '1.5.1'
|
kotlinCompilerExtensionVersion '1.5.10'
|
||||||
}
|
}
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
resources {
|
resources {
|
||||||
@ -91,41 +92,60 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.core:core-ktx:1.12.0'
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
|
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
|
||||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
implementation 'androidx.activity:activity-compose:1.9.1'
|
||||||
implementation platform('androidx.compose:compose-bom:2022.10.00')
|
implementation 'androidx.activity:activity-ktx:1.9.1'
|
||||||
|
implementation platform('androidx.compose:compose-bom:2024.09.00')
|
||||||
implementation 'androidx.compose.ui:ui'
|
implementation 'androidx.compose.ui:ui'
|
||||||
implementation 'androidx.compose.ui:ui-graphics'
|
implementation 'androidx.compose.ui:ui-graphics'
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
implementation 'androidx.compose.material3:material3'
|
implementation 'androidx.compose.material3:material3:1.2.1'
|
||||||
implementation "androidx.compose.material:material-icons-extended:1.5.1"
|
implementation "androidx.compose.material:material-icons-extended:1.6.8"
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||||
|
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||||
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
|
androidTestImplementation platform('androidx.compose:compose-bom:2024.09.00')
|
||||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||||
|
|
||||||
implementation "androidx.navigation:navigation-compose:2.7.2"
|
implementation "androidx.navigation:navigation-compose:2.7.7"
|
||||||
|
|
||||||
implementation 'com.google.dagger:hilt-android:2.46.1'
|
implementation 'com.google.dagger:hilt-android:2.49'
|
||||||
annotationProcessor 'com.google.dagger:hilt-compiler:2.46.1'
|
annotationProcessor 'com.google.dagger:hilt-compiler:2.49'
|
||||||
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
|
implementation "androidx.hilt:hilt-navigation-compose:1.2.0"
|
||||||
|
|
||||||
implementation 'com.arthenica:ffmpeg-kit-min-gpl:5.1'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.1'
|
||||||
|
|
||||||
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1'
|
||||||
|
|
||||||
|
implementation "androidx.datastore:datastore-preferences:1.1.1"
|
||||||
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
|
||||||
|
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:core:1.2.0'
|
implementation 'com.maxkeppeler.sheets-compose-dialogs:core:1.2.0'
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:duration:1.2.0'
|
implementation 'com.maxkeppeler.sheets-compose-dialogs:duration:1.2.0'
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
|
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
|
||||||
implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0'
|
implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0'
|
||||||
|
|
||||||
|
def camerax_version = "1.3.4"
|
||||||
|
implementation "androidx.camera:camera-core:${camerax_version}"
|
||||||
|
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
||||||
|
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
||||||
|
implementation "androidx.camera:camera-video:${camerax_version}"
|
||||||
|
|
||||||
|
implementation "androidx.camera:camera-view:${camerax_version}"
|
||||||
|
implementation "androidx.camera:camera-extensions:${camerax_version}"
|
||||||
|
|
||||||
|
|
||||||
|
implementation "com.valentinilk.shimmer:compose-shimmer:1.2.0"
|
||||||
|
|
||||||
|
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||||
}
|
}
|
@ -2,17 +2,40 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.microphone"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<!-- Required for Bluetooth microphones -->
|
<!-- Required for Bluetooth microphones -->
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.MODIFY_AUDIO_SETTINGS"
|
android:name="android.permission.MODIFY_AUDIO_SETTINGS"
|
||||||
android:maxSdkVersion="30" />
|
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.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
|
||||||
|
<!-- Starting with Android 29, apps don't need to request the READ_EXTERNAL_STORAGE permission
|
||||||
|
for files in their own MediaStore -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".UpdateSettingsApp"
|
android:name=".UpdateSettingsApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@ -23,6 +46,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Alibi"
|
android:theme="@style/Theme.Alibi"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
@ -43,9 +67,13 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".services.AudioRecorderService"
|
android:name=".services.AudioRecorderService"
|
||||||
android:foregroundServiceType="microphone" />
|
android:foregroundServiceType="microphone" />
|
||||||
|
<service
|
||||||
|
android:name=".services.VideoRecorderService"
|
||||||
|
android:foregroundServiceType="camera|microphone" />
|
||||||
|
|
||||||
<!-- Change locale for Android <= 12 -->
|
<!-- Change locale for Android <= 12 -->
|
||||||
<service
|
<service
|
||||||
|
@ -2,19 +2,19 @@ package app.myzel394.alibi
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.datastore.dataStore
|
import androidx.datastore.dataStore
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.db.AppSettingsSerializer
|
import app.myzel394.alibi.db.AppSettingsSerializer
|
||||||
|
import app.myzel394.alibi.ui.AsLockedApp
|
||||||
|
import app.myzel394.alibi.ui.LockedAppHandlers
|
||||||
import app.myzel394.alibi.ui.Navigation
|
import app.myzel394.alibi.ui.Navigation
|
||||||
import app.myzel394.alibi.ui.SUPPORTS_DARK_MODE_NATIVELY
|
|
||||||
import app.myzel394.alibi.ui.theme.AlibiTheme
|
import app.myzel394.alibi.ui.theme.AlibiTheme
|
||||||
|
|
||||||
const val SETTINGS_FILE = "settings.json"
|
const val SETTINGS_FILE = "settings.json"
|
||||||
@ -30,27 +30,21 @@ class MainActivity : AppCompatActivity() {
|
|||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val dataStore = LocalContext.current.dataStore
|
AlibiTheme {
|
||||||
val settings = dataStore
|
LockedAppHandlers()
|
||||||
.data
|
|
||||||
.collectAsState(initial = AppSettings.getDefaultInstance())
|
|
||||||
.value
|
|
||||||
|
|
||||||
LaunchedEffect(settings.theme) {
|
Box(
|
||||||
if (!SUPPORTS_DARK_MODE_NATIVELY) {
|
modifier = Modifier
|
||||||
val currentValue = AppCompatDelegate.getDefaultNightMode()
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
if (settings.theme == AppSettings.Theme.LIGHT && currentValue != AppCompatDelegate.MODE_NIGHT_NO) {
|
MaterialTheme.colorScheme.background
|
||||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
)
|
||||||
} else if (settings.theme == AppSettings.Theme.DARK && currentValue != AppCompatDelegate.MODE_NIGHT_YES) {
|
) {
|
||||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
AsLockedApp {
|
||||||
|
Navigation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AlibiTheme {
|
|
||||||
Navigation()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,24 +1,45 @@
|
|||||||
package app.myzel394.alibi.db
|
package app.myzel394.alibi.db
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.camera.video.Quality
|
||||||
|
import androidx.camera.video.QualitySelector
|
||||||
import app.myzel394.alibi.R
|
import app.myzel394.alibi.R
|
||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
import app.myzel394.alibi.helpers.VideoBatchesFolder
|
||||||
|
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||||
|
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
||||||
|
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.RecorderModel
|
||||||
|
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppSettings(
|
data class AppSettings(
|
||||||
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings(),
|
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings.getDefaultInstance(),
|
||||||
val notificationSettings: NotificationSettings? = null,
|
val videoRecorderSettings: VideoRecorderSettings = VideoRecorderSettings.getDefaultInstance(),
|
||||||
|
|
||||||
|
val appLockSettings: AppLockSettings? = null,
|
||||||
|
|
||||||
val hasSeenOnboarding: Boolean = false,
|
val hasSeenOnboarding: Boolean = false,
|
||||||
val showAdvancedSettings: Boolean = false,
|
val showAdvancedSettings: Boolean = false,
|
||||||
val theme: Theme = Theme.SYSTEM,
|
val theme: Theme = Theme.SYSTEM,
|
||||||
val lastRecording: RecordingInformation? = null,
|
val lastRecording: RecordingInformation? = null,
|
||||||
|
|
||||||
|
val filenameFormat: FilenameFormat = FilenameFormat.DATETIME_RELATIVE_START,
|
||||||
|
|
||||||
|
/// Recording information
|
||||||
|
// 30 minutes
|
||||||
|
val maxDuration: Long = 15 * 60 * 1000L,
|
||||||
|
// 60 seconds
|
||||||
|
val intervalDuration: Long = 60 * 1000L,
|
||||||
|
|
||||||
|
val notificationSettings: NotificationSettings? = null,
|
||||||
|
val deleteRecordingsImmediately: Boolean = false,
|
||||||
|
val saveFolder: String? = null,
|
||||||
) {
|
) {
|
||||||
fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings {
|
fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings {
|
||||||
return copy(showAdvancedSettings = showAdvancedSettings)
|
return copy(showAdvancedSettings = showAdvancedSettings)
|
||||||
@ -28,6 +49,10 @@ data class AppSettings(
|
|||||||
return copy(audioRecorderSettings = audioRecorderSettings)
|
return copy(audioRecorderSettings = audioRecorderSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setVideoRecorderSettings(videoRecorderSettings: VideoRecorderSettings): AppSettings {
|
||||||
|
return copy(videoRecorderSettings = videoRecorderSettings)
|
||||||
|
}
|
||||||
|
|
||||||
fun setNotificationSettings(notificationSettings: NotificationSettings?): AppSettings {
|
fun setNotificationSettings(notificationSettings: NotificationSettings?): AppSettings {
|
||||||
return copy(notificationSettings = notificationSettings)
|
return copy(notificationSettings = notificationSettings)
|
||||||
}
|
}
|
||||||
@ -44,14 +69,81 @@ data class AppSettings(
|
|||||||
return copy(lastRecording = lastRecording)
|
return copy(lastRecording = lastRecording)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setFilenameFormat(filenameFormat: FilenameFormat): AppSettings {
|
||||||
|
return copy(filenameFormat = filenameFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMaxDuration(duration: Long): AppSettings {
|
||||||
|
if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) {
|
||||||
|
throw Exception("Max duration must be between 1 minute and 10 days")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration < intervalDuration) {
|
||||||
|
throw Exception("Max duration must be greater than interval duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy(maxDuration = duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setIntervalDuration(duration: Long): AppSettings {
|
||||||
|
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
|
||||||
|
throw Exception("Interval duration must be between 10 seconds and 1 hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration > maxDuration) {
|
||||||
|
throw Exception("Interval duration must be less than max duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy(intervalDuration = duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDeleteRecordingsImmediately(deleteRecordingsImmediately: Boolean): AppSettings {
|
||||||
|
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSaveFolder(saveFolder: String?): AppSettings {
|
||||||
|
return copy(saveFolder = saveFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAppLockSettings(appLockSettings: AppLockSettings?): AppSettings {
|
||||||
|
return copy(appLockSettings = appLockSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLastRecording(recorder: RecorderModel): AppSettings {
|
||||||
|
return if (deleteRecordingsImmediately) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
setLastRecording(
|
||||||
|
recorder.recorderService!!.getRecordingInformation()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the object is present, biometric authentication is enabled.
|
||||||
|
// To disable biometric authentication, set the instance to null.
|
||||||
|
fun isAppLockEnabled() = appLockSettings != null
|
||||||
|
|
||||||
|
fun requiresExternalStoragePermission(context: Context): Boolean {
|
||||||
|
return !SUPPORTS_SCOPED_STORAGE && (saveFolder == RECORDER_MEDIA_SELECTED_VALUE && !PermissionHelper.hasGranted(
|
||||||
|
context,
|
||||||
|
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportToString(): String {
|
||||||
|
return Json.encodeToString(serializer(), this)
|
||||||
|
}
|
||||||
|
|
||||||
enum class Theme {
|
enum class Theme {
|
||||||
SYSTEM,
|
SYSTEM,
|
||||||
LIGHT,
|
LIGHT,
|
||||||
DARK,
|
DARK,
|
||||||
}
|
}
|
||||||
|
|
||||||
fun exportToString(): String {
|
enum class FilenameFormat {
|
||||||
return Json.encodeToString(serializer(), this)
|
DATETIME_ABSOLUTE_START,
|
||||||
|
DATETIME_RELATIVE_START,
|
||||||
|
DATETIME_NOW,
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -71,29 +163,52 @@ data class RecordingInformation(
|
|||||||
val folderPath: String,
|
val folderPath: String,
|
||||||
@Serializable(with = LocalDateTimeSerializer::class)
|
@Serializable(with = LocalDateTimeSerializer::class)
|
||||||
val recordingStart: LocalDateTime,
|
val recordingStart: LocalDateTime,
|
||||||
|
val batchesAmount: Int,
|
||||||
val maxDuration: Long,
|
val maxDuration: Long,
|
||||||
val intervalDuration: Long,
|
val intervalDuration: Long,
|
||||||
val fileExtension: String,
|
val fileExtension: String,
|
||||||
val forceExactMaxDuration: Boolean,
|
val type: Type,
|
||||||
) {
|
) {
|
||||||
val hasRecordingsAvailable
|
fun hasRecordingsAvailable(context: Context): Boolean =
|
||||||
get() = File(folderPath).listFiles()?.isNotEmpty() ?: false
|
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
|
@Serializable
|
||||||
data class AudioRecorderSettings(
|
data class AudioRecorderSettings(
|
||||||
// 30 minutes
|
|
||||||
val maxDuration: Long = 30 * 60 * 1000L,
|
|
||||||
// 60 seconds
|
|
||||||
val intervalDuration: Long = 60 * 1000L,
|
|
||||||
val forceExactMaxDuration: Boolean = true,
|
|
||||||
// 320 Kbps
|
// 320 Kbps
|
||||||
val bitRate: Int = 320000,
|
val bitRate: Int = 320000,
|
||||||
val samplingRate: Int? = null,
|
val samplingRate: Int? = null,
|
||||||
val outputFormat: Int? = null,
|
val outputFormat: Int? = null,
|
||||||
val encoder: Int? = null,
|
val encoder: Int? = null,
|
||||||
val showAllMicrophones: Boolean = false,
|
val showAllMicrophones: Boolean = false,
|
||||||
val deleteRecordingsImmediately: Boolean = false,
|
|
||||||
) {
|
) {
|
||||||
fun getOutputFormat(): Int {
|
fun getOutputFormat(): Int {
|
||||||
if (outputFormat != null) {
|
if (outputFormat != null) {
|
||||||
@ -161,18 +276,6 @@ data class AudioRecorderSettings(
|
|||||||
else
|
else
|
||||||
MediaRecorder.AudioEncoder.AMR_NB
|
MediaRecorder.AudioEncoder.AMR_NB
|
||||||
|
|
||||||
fun setIntervalDuration(duration: Long): AudioRecorderSettings {
|
|
||||||
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
|
|
||||||
throw Exception("Interval duration must be between 10 seconds and 1 hour")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration > maxDuration) {
|
|
||||||
throw Exception("Interval duration must be less than max duration")
|
|
||||||
}
|
|
||||||
|
|
||||||
return copy(intervalDuration = duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
fun setBitRate(bitRate: Int): AudioRecorderSettings {
|
||||||
if (bitRate !in 1000..320000) {
|
if (bitRate !in 1000..320000) {
|
||||||
throw Exception("Bit rate must be between 1000 and 320000")
|
throw Exception("Bit rate must be between 1000 and 320000")
|
||||||
@ -205,30 +308,10 @@ data class AudioRecorderSettings(
|
|||||||
return copy(encoder = encoder)
|
return copy(encoder = encoder)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMaxDuration(duration: Long): AudioRecorderSettings {
|
|
||||||
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 setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings {
|
|
||||||
return copy(forceExactMaxDuration = forceExactMaxDuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
|
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
|
||||||
return copy(showAllMicrophones = showAllMicrophones)
|
return copy(showAllMicrophones = showAllMicrophones)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDeleteRecordingsImmediately(deleteRecordingsImmediately: Boolean): AudioRecorderSettings {
|
|
||||||
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isEncoderCompatible(encoder: Int): Boolean {
|
fun isEncoderCompatible(encoder: Int): Boolean {
|
||||||
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
|
if (outputFormat == null || outputFormat == MediaRecorder.OutputFormat.DEFAULT) {
|
||||||
return true
|
return true
|
||||||
@ -239,17 +322,31 @@ data class AudioRecorderSettings(
|
|||||||
return supportedFormats.contains(outputFormat)
|
return supportedFormats.contains(outputFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val fileExtension: String
|
||||||
|
get() = when (getOutputFormat()) {
|
||||||
|
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
||||||
|
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
||||||
|
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
||||||
|
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
||||||
|
MediaRecorder.OutputFormat.WEBM -> "webm"
|
||||||
|
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
||||||
|
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
||||||
|
MediaRecorder.OutputFormat.OGG -> "ogg"
|
||||||
|
else -> "raw"
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
|
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
|
||||||
val EXAMPLE_MAX_DURATIONS = listOf(
|
val EXAMPLE_MAX_DURATIONS = listOf(
|
||||||
|
1 * 60 * 1000L,
|
||||||
|
5 * 60 * 1000L,
|
||||||
15 * 60 * 1000L,
|
15 * 60 * 1000L,
|
||||||
30 * 60 * 1000L,
|
30 * 60 * 1000L,
|
||||||
60 * 60 * 1000L,
|
60 * 60 * 1000L,
|
||||||
2 * 60 * 60 * 1000L,
|
|
||||||
3 * 60 * 60 * 1000L,
|
|
||||||
)
|
)
|
||||||
val EXAMPLE_DURATION_TIMES = listOf(
|
val EXAMPLE_DURATION_TIMES = listOf(
|
||||||
60 * 1000L,
|
60 * 1000L,
|
||||||
|
60 * 2 * 1000L,
|
||||||
60 * 5 * 1000L,
|
60 * 5 * 1000L,
|
||||||
60 * 10 * 1000L,
|
60 * 10 * 1000L,
|
||||||
60 * 15 * 1000L,
|
60 * 15 * 1000L,
|
||||||
@ -348,6 +445,93 @@ data class AudioRecorderSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Serializable
|
||||||
data class NotificationSettings(
|
data class NotificationSettings(
|
||||||
val title: String,
|
val title: String,
|
||||||
@ -366,39 +550,39 @@ data class NotificationSettings(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Default : Preset(
|
data object Default : Preset(
|
||||||
R.string.ui_audioRecorder_state_recording_title,
|
R.string.ui_audioRecorder_state_recording_title,
|
||||||
R.string.ui_audioRecorder_state_recording_description,
|
R.string.ui_recorder_state_recording_description,
|
||||||
true,
|
true,
|
||||||
R.drawable.launcher_monochrome_noopacity,
|
R.drawable.launcher_monochrome_noopacity,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Weather : Preset(
|
data object Weather : Preset(
|
||||||
R.string.ui_audioRecorder_state_recording_fake_weather_title,
|
R.string.ui_recorder_state_recording_fake_weather_title,
|
||||||
R.string.ui_audioRecorder_state_recording_fake_weather_description,
|
R.string.ui_recorder_state_recording_fake_weather_description,
|
||||||
false,
|
false,
|
||||||
R.drawable.ic_cloud
|
R.drawable.ic_cloud
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Player : Preset(
|
data object Player : Preset(
|
||||||
R.string.ui_audioRecorder_state_recording_fake_player_title,
|
R.string.ui_recorder_state_recording_fake_player_title,
|
||||||
R.string.ui_audioRecorder_state_recording_fake_player_description,
|
R.string.ui_recorder_state_recording_fake_player_description,
|
||||||
true,
|
true,
|
||||||
R.drawable.ic_note,
|
R.drawable.ic_note,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Browser : Preset(
|
data object Browser : Preset(
|
||||||
R.string.ui_audioRecorder_state_recording_fake_browser_title,
|
R.string.ui_recorder_state_recording_fake_browser_title,
|
||||||
R.string.ui_audioRecorder_state_recording_fake_browser_description,
|
R.string.ui_recorder_state_recording_fake_browser_description,
|
||||||
true,
|
true,
|
||||||
R.drawable.ic_download,
|
R.drawable.ic_download,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object VPN : Preset(
|
data object VPN : Preset(
|
||||||
R.string.ui_audioRecorder_state_recording_fake_vpn_title,
|
R.string.ui_recorder_state_recording_fake_vpn_title,
|
||||||
R.string.ui_audioRecorder_state_recording_fake_vpn_description,
|
R.string.ui_recorder_state_recording_fake_vpn_description,
|
||||||
false,
|
false,
|
||||||
R.drawable.ic_vpn,
|
R.drawable.ic_vpn,
|
||||||
)
|
)
|
||||||
@ -424,3 +608,10 @@ data class NotificationSettings(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class AppLockSettings {
|
||||||
|
companion object {
|
||||||
|
fun getDefaultInstance() = AppLockSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
package app.myzel394.alibi.enums
|
package app.myzel394.alibi.enums
|
||||||
|
|
||||||
enum class RecorderState {
|
enum class RecorderState {
|
||||||
IDLE,
|
STOPPED,
|
||||||
RECORDING,
|
RECORDING,
|
||||||
PAUSED,
|
PAUSED,
|
||||||
|
|
||||||
|
// Only used by the model to indicate that the service is not running
|
||||||
|
IDLE
|
||||||
}
|
}
|
@ -0,0 +1,76 @@
|
|||||||
|
package app.myzel394.alibi.helpers
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class AppLockHelper {
|
||||||
|
enum class SupportType {
|
||||||
|
AVAILABLE,
|
||||||
|
UNAVAILABLE,
|
||||||
|
NONE_ENROLLED,
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getSupportType(context: Context): SupportType {
|
||||||
|
val biometricManager = BiometricManager.from(context)
|
||||||
|
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)) {
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS -> SupportType.AVAILABLE
|
||||||
|
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> SupportType.NONE_ENROLLED
|
||||||
|
|
||||||
|
else -> SupportType.UNAVAILABLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authenticate(
|
||||||
|
context: Context,
|
||||||
|
title: String,
|
||||||
|
subtitle: String
|
||||||
|
): CompletableDeferred<Boolean> {
|
||||||
|
val deferred = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||||
|
val biometricPrompt = BiometricPrompt(
|
||||||
|
context as FragmentActivity,
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
deferred.complete(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
deferred.complete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationFailed() {
|
||||||
|
deferred.complete(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(title)
|
||||||
|
.setSubtitle(subtitle)
|
||||||
|
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
biometricPrompt.authenticate(promptInfo)
|
||||||
|
|
||||||
|
return deferred
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeApp(context: Context) {
|
||||||
|
(context as? Activity)?.let {
|
||||||
|
it.finishAndRemoveTask()
|
||||||
|
it.finishAffinity()
|
||||||
|
it.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,176 @@
|
|||||||
|
package app.myzel394.alibi.helpers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import app.myzel394.alibi.helpers.MediaConverter.Companion.concatenateAudioFiles
|
||||||
|
import app.myzel394.alibi.ui.AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME
|
||||||
|
import app.myzel394.alibi.ui.MEDIA_SUBFOLDER_NAME
|
||||||
|
import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE
|
||||||
|
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||||
|
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
class AudioBatchesFolder(
|
||||||
|
override val context: Context,
|
||||||
|
override val type: BatchType,
|
||||||
|
override val customFolder: DocumentFile? = null,
|
||||||
|
override val subfolderName: String = AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME,
|
||||||
|
) : BatchesFolder(
|
||||||
|
context,
|
||||||
|
type,
|
||||||
|
customFolder,
|
||||||
|
subfolderName,
|
||||||
|
) {
|
||||||
|
override val concatenationFunction = ::concatenateAudioFiles
|
||||||
|
override val ffmpegParameters = FFMPEG_PARAMETERS
|
||||||
|
override val scopedMediaContentUri: Uri = SCOPED_MEDIA_CONTENT_URI
|
||||||
|
override val legacyMediaFolder = LEGACY_MEDIA_FOLDER
|
||||||
|
|
||||||
|
private var customFileFileDescriptor: ParcelFileDescriptor? = null
|
||||||
|
private var mediaFileFileDescriptor: ParcelFileDescriptor? = null
|
||||||
|
|
||||||
|
override fun getOutputFileForFFmpeg(
|
||||||
|
date: LocalDateTime,
|
||||||
|
extension: String,
|
||||||
|
fileName: String,
|
||||||
|
): String {
|
||||||
|
return when (type) {
|
||||||
|
BatchType.INTERNAL -> asInternalGetOutputFile(fileName).absolutePath
|
||||||
|
|
||||||
|
BatchType.CUSTOM -> {
|
||||||
|
FFmpegKitConfig.getSafParameterForWrite(
|
||||||
|
context,
|
||||||
|
(customFolder!!.findFile(fileName) ?: customFolder.createFile(
|
||||||
|
"audio/${extension}",
|
||||||
|
fileName,
|
||||||
|
)!!).uri
|
||||||
|
)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
BatchType.MEDIA -> {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val mediaUri = getOrCreateMediaFile(
|
||||||
|
name = fileName,
|
||||||
|
mimeType = "audio/$extension",
|
||||||
|
relativePath = BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_SUBFOLDER_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
return FFmpegKitConfig.getSafParameterForWrite(
|
||||||
|
context,
|
||||||
|
mediaUri
|
||||||
|
)!!
|
||||||
|
} else {
|
||||||
|
val path = arrayOf(
|
||||||
|
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||||
|
MEDIA_SUBFOLDER_NAME,
|
||||||
|
fileName,
|
||||||
|
).joinToString("/")
|
||||||
|
return File(path)
|
||||||
|
.apply {
|
||||||
|
createNewFile()
|
||||||
|
}.absolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cleanup() {
|
||||||
|
runCatching {
|
||||||
|
customFileFileDescriptor?.close()
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
mediaFileFileDescriptor?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun asCustomGetFileDescriptor(
|
||||||
|
counter: Long,
|
||||||
|
fileExtension: String,
|
||||||
|
): FileDescriptor {
|
||||||
|
runCatching {
|
||||||
|
customFileFileDescriptor?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val file =
|
||||||
|
getCustomDefinedFolder().createFile("audio/$fileExtension", "$counter.$fileExtension")!!
|
||||||
|
|
||||||
|
customFileFileDescriptor = context.contentResolver.openFileDescriptor(file.uri, "w")!!
|
||||||
|
|
||||||
|
return customFileFileDescriptor!!.fileDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
fun asMediaGetScopedStorageFileDescriptor(
|
||||||
|
name: String,
|
||||||
|
mimeType: String
|
||||||
|
): FileDescriptor {
|
||||||
|
runCatching {
|
||||||
|
mediaFileFileDescriptor?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaUri = getOrCreateMediaFile(
|
||||||
|
name = name,
|
||||||
|
mimeType = mimeType,
|
||||||
|
relativePath = SCOPED_STORAGE_RELATIVE_PATH,
|
||||||
|
)
|
||||||
|
|
||||||
|
mediaFileFileDescriptor = context.contentResolver.openFileDescriptor(mediaUri, "w")!!
|
||||||
|
|
||||||
|
return mediaFileFileDescriptor!!.fileDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun viaInternalFolder(context: Context) = AudioBatchesFolder(context, BatchType.INTERNAL)
|
||||||
|
|
||||||
|
fun viaCustomFolder(context: Context, folder: DocumentFile) =
|
||||||
|
AudioBatchesFolder(context, BatchType.CUSTOM, folder)
|
||||||
|
|
||||||
|
fun viaMediaFolder(context: Context) = AudioBatchesFolder(context, BatchType.MEDIA)
|
||||||
|
|
||||||
|
fun importFromFolder(folder: String, context: Context) = when (folder) {
|
||||||
|
RECORDER_INTERNAL_SELECTED_VALUE -> viaInternalFolder(context)
|
||||||
|
RECORDER_MEDIA_SELECTED_VALUE -> viaMediaFolder(context)
|
||||||
|
else -> viaCustomFolder(context, DocumentFile.fromTreeUri(context, Uri.parse(folder))!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
val BASE_LEGACY_STORAGE_FOLDER = Environment.DIRECTORY_PODCASTS
|
||||||
|
val MEDIA_RECORDINGS_SUBFOLDER = MEDIA_SUBFOLDER_NAME + "/.audio_recordings"
|
||||||
|
val BASE_SCOPED_STORAGE_RELATIVE_PATH =
|
||||||
|
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
Environment.DIRECTORY_RECORDINGS
|
||||||
|
else
|
||||||
|
Environment.DIRECTORY_PODCASTS)
|
||||||
|
val SCOPED_STORAGE_RELATIVE_PATH =
|
||||||
|
BASE_SCOPED_STORAGE_RELATIVE_PATH + "/" + MEDIA_RECORDINGS_SUBFOLDER
|
||||||
|
|
||||||
|
// Don't use those values directly, use the constants from the instance.
|
||||||
|
// Those values are only used inside the `SaveFolderTile`
|
||||||
|
val SCOPED_MEDIA_CONTENT_URI = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
val LEGACY_MEDIA_FOLDER = File(
|
||||||
|
Environment.getExternalStoragePublicDirectory(BASE_LEGACY_STORAGE_FOLDER),
|
||||||
|
MEDIA_RECORDINGS_SUBFOLDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// Parameters to be passed in descending order
|
||||||
|
// Those parameters first try to concatenate without re-encoding
|
||||||
|
// if that fails, it'll try several fallback methods
|
||||||
|
// this is audio only
|
||||||
|
val FFMPEG_PARAMETERS = arrayOf(
|
||||||
|
" -c copy",
|
||||||
|
" -acodec copy",
|
||||||
|
" -c:a aac",
|
||||||
|
" -c:a libmp3lame",
|
||||||
|
" -c:a libopus",
|
||||||
|
" -c:a libvorbis",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -1,118 +0,0 @@
|
|||||||
package app.myzel394.alibi.helpers
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import app.myzel394.alibi.db.RecordingInformation
|
|
||||||
import app.myzel394.alibi.ui.RECORDER_SUBFOLDER_NAME
|
|
||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
|
||||||
import java.io.File
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
data class AudioRecorderExporter(
|
|
||||||
val recording: RecordingInformation,
|
|
||||||
) {
|
|
||||||
val filePaths: List<File>
|
|
||||||
get() =
|
|
||||||
File(recording.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("${recording.folderPath}/${outputFile.nameWithoutExtension}-raw.${recording.fileExtension}")
|
|
||||||
outputFile.renameTo(rawFile)
|
|
||||||
|
|
||||||
val command = "-sseof ${recording.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.state,
|
|
||||||
session.returnCode,
|
|
||||||
session.failStackTrace,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
throw Exception("Failed to strip concatenated audio")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
|
|
||||||
val paths = filePaths.joinToString("|")
|
|
||||||
val fileName = recording.recordingStart
|
|
||||||
.format(DateTimeFormatter.ISO_DATE_TIME)
|
|
||||||
.toString()
|
|
||||||
.replace(":", "-")
|
|
||||||
.replace(".", "_")
|
|
||||||
val outputFile = File("${recording.folderPath}/$fileName.${recording.fileExtension}")
|
|
||||||
|
|
||||||
if (outputFile.exists() && !forceConcatenation) {
|
|
||||||
return outputFile
|
|
||||||
}
|
|
||||||
|
|
||||||
val command = "-i 'concat:$paths' -y" +
|
|
||||||
" -acodec copy" +
|
|
||||||
" -metadata title='$fileName' " +
|
|
||||||
" -metadata date='${recording.recordingStart.format(DateTimeFormatter.ISO_DATE_TIME)}'" +
|
|
||||||
" -metadata batch_count='${filePaths.size}'" +
|
|
||||||
" -metadata batch_duration='${recording.intervalDuration}'" +
|
|
||||||
" -metadata max_duration='${recording.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.state,
|
|
||||||
session.returnCode,
|
|
||||||
session.failStackTrace,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
throw Exception("Failed to concatenate audios")
|
|
||||||
}
|
|
||||||
|
|
||||||
val minRequiredForPossibleInExactMaxDuration =
|
|
||||||
recording.maxDuration / recording.intervalDuration
|
|
||||||
if (recording.forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
|
|
||||||
stripConcatenatedFileToExactDuration(outputFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
return outputFile
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun cleanupFiles() {
|
|
||||||
filePaths.forEach {
|
|
||||||
runCatching {
|
|
||||||
it.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun getFolder(context: Context) = File(context.filesDir, RECORDER_SUBFOLDER_NAME)
|
|
||||||
|
|
||||||
fun clearAllRecordings(context: Context) {
|
|
||||||
getFolder(context).deleteRecursively()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasRecordingsAvailable(context: Context) =
|
|
||||||
getFolder(context).listFiles()?.isNotEmpty() ?: false
|
|
||||||
}
|
|
||||||
}
|
|
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,7 +1,7 @@
|
|||||||
package app.myzel394.alibi.services
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.media.AudioDeviceCallback
|
import android.media.AudioDeviceCallback
|
||||||
import android.media.AudioDeviceInfo
|
import android.media.AudioDeviceInfo
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
@ -10,25 +10,129 @@ import android.media.MediaRecorder.OnErrorListener
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.core.content.ContextCompat.getSystemService
|
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.enums.RecorderState
|
||||||
|
import app.myzel394.alibi.helpers.AudioBatchesFolder
|
||||||
|
import app.myzel394.alibi.helpers.BatchesFolder
|
||||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
import java.lang.IllegalStateException
|
|
||||||
import java.util.concurrent.Executor
|
|
||||||
|
|
||||||
class AudioRecorderService : IntervalRecorderService() {
|
class AudioRecorderService :
|
||||||
|
IntervalRecorderService<RecordingInformation, AudioBatchesFolder>() {
|
||||||
|
override var batchesFolder = AudioBatchesFolder.viaInternalFolder(this)
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
var amplitudes = mutableListOf<Int>()
|
||||||
|
private set
|
||||||
var amplitudesAmount = 1000
|
var amplitudesAmount = 1000
|
||||||
|
|
||||||
var selectedMicrophone: MicrophoneInfo? = null
|
var selectedMicrophone: MicrophoneInfo? = null
|
||||||
|
|
||||||
var recorder: MediaRecorder? = null
|
var recorder: MediaRecorder? = null
|
||||||
private set
|
private set
|
||||||
var onError: () -> Unit = {}
|
|
||||||
|
// Callbacks
|
||||||
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
|
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
|
||||||
var onMicrophoneDisconnected: () -> Unit = {}
|
var onMicrophoneDisconnected: () -> Unit = {}
|
||||||
var onMicrophoneReconnected: () -> Unit = {}
|
var onMicrophoneReconnected: () -> Unit = {}
|
||||||
|
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
|
||||||
|
|
||||||
val filePath: String
|
override fun startNewCycle() {
|
||||||
get() = "${outputFolder}/$counter.${settings!!.fileExtension}"
|
super.startNewCycle()
|
||||||
|
|
||||||
|
val newRecorder = createRecorder().also {
|
||||||
|
it.prepare()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetRecorder()
|
||||||
|
startAudioDevice()
|
||||||
|
|
||||||
|
try {
|
||||||
|
recorder = newRecorder
|
||||||
|
newRecorder.start()
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
super.start()
|
||||||
|
|
||||||
|
createAmplitudesTimer()
|
||||||
|
registerMicrophoneListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pause() {
|
||||||
|
super.pause()
|
||||||
|
|
||||||
|
resetRecorder()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stop() {
|
||||||
|
resetRecorder()
|
||||||
|
unregisterMicrophoneListener()
|
||||||
|
|
||||||
|
super.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resume() {
|
||||||
|
super.resume()
|
||||||
|
createAmplitudesTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
/// Tell Android to use the correct bluetooth microphone, if any selected
|
||||||
private fun startAudioDevice() {
|
private fun startAudioDevice() {
|
||||||
@ -55,12 +159,18 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getNameForMediaFile() =
|
||||||
|
"${batchesFolder.mediaPrefix}$counter.${settings.audioRecorderSettings.fileExtension}"
|
||||||
|
|
||||||
|
// ==== Actual recording related ====
|
||||||
private fun createRecorder(): MediaRecorder {
|
private fun createRecorder(): MediaRecorder {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
MediaRecorder(this)
|
MediaRecorder(this)
|
||||||
} else {
|
} else {
|
||||||
MediaRecorder()
|
MediaRecorder()
|
||||||
}.apply {
|
}.apply {
|
||||||
|
val audioSettings = settings.audioRecorderSettings
|
||||||
|
|
||||||
// Audio Source is kinda strange, here are my experimental findings using a Pixel 7 Pro
|
// Audio Source is kinda strange, here are my experimental findings using a Pixel 7 Pro
|
||||||
// and Redmi Buds 3 Pro:
|
// and Redmi Buds 3 Pro:
|
||||||
// - MIC: Uses the bottom microphone of the phone (17)
|
// - MIC: Uses the bottom microphone of the phone (17)
|
||||||
@ -68,74 +178,64 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
|
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
|
||||||
// - DEFAULT: Uses the bottom microphone of the phone (17)
|
// - DEFAULT: Uses the bottom microphone of the phone (17)
|
||||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
setOutputFile(filePath)
|
|
||||||
setOutputFormat(settings!!.outputFormat)
|
when (batchesFolder.type) {
|
||||||
setAudioEncoder(settings!!.encoder)
|
BatchesFolder.BatchType.INTERNAL -> {
|
||||||
setAudioEncodingBitRate(settings!!.bitRate)
|
setOutputFile(
|
||||||
setAudioSamplingRate(settings!!.samplingRate)
|
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 { _, _, _ ->
|
setOnErrorListener(OnErrorListener { _, _, _ ->
|
||||||
onError()
|
onError()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==== Microphone related ====
|
||||||
private fun resetRecorder() {
|
private fun resetRecorder() {
|
||||||
runCatching {
|
runCatching {
|
||||||
recorder?.let {
|
recorder?.apply {
|
||||||
it.stop()
|
stop()
|
||||||
it.release()
|
reset()
|
||||||
|
release()
|
||||||
}
|
}
|
||||||
clearAudioDevice()
|
clearAudioDevice()
|
||||||
}
|
batchesFolder.cleanup()
|
||||||
}
|
|
||||||
|
|
||||||
override fun startNewCycle() {
|
|
||||||
super.startNewCycle()
|
|
||||||
|
|
||||||
val newRecorder = createRecorder().also {
|
|
||||||
it.prepare()
|
|
||||||
}
|
|
||||||
|
|
||||||
resetRecorder()
|
|
||||||
startAudioDevice()
|
|
||||||
|
|
||||||
try {
|
|
||||||
recorder = newRecorder
|
|
||||||
newRecorder.start()
|
|
||||||
} catch (error: RuntimeException) {
|
|
||||||
onError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
super.start()
|
|
||||||
|
|
||||||
registerMicrophoneListener()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pause() {
|
|
||||||
super.pause()
|
|
||||||
|
|
||||||
resetRecorder()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
super.stop()
|
|
||||||
|
|
||||||
resetRecorder()
|
|
||||||
selectedMicrophone = null
|
|
||||||
unregisterMicrophoneListener()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAmplitudeAmount(): Int = amplitudesAmount
|
|
||||||
|
|
||||||
override fun getAmplitude(): Int {
|
|
||||||
return try {
|
|
||||||
recorder!!.maxAmplitude
|
|
||||||
} catch (error: IllegalStateException) {
|
|
||||||
0
|
|
||||||
} catch (error: RuntimeException) {
|
|
||||||
0
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,7 +253,7 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
super.onAudioDevicesAdded(addedDevices)
|
super.onAudioDevicesAdded(addedDevices)
|
||||||
|
|
||||||
if (selectedMicrophone == null) {
|
if (selectedMicrophone == null) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// We can't compare the ID, as it seems to be changing on each reconnect
|
// We can't compare the ID, as it seems to be changing on each reconnect
|
||||||
@ -177,7 +277,7 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
super.onAudioDevicesRemoved(removedDevices)
|
super.onAudioDevicesRemoved(removedDevices)
|
||||||
|
|
||||||
if (selectedMicrophone == null) {
|
if (selectedMicrophone == null) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
|
if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
|
||||||
@ -200,4 +300,16 @@ class AudioRecorderService : IntervalRecorderService() {
|
|||||||
|
|
||||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
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,55 +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()) {
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start() {
|
|
||||||
createAmplitudesTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resume() {
|
|
||||||
createAmplitudesTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,43 +1,45 @@
|
|||||||
package app.myzel394.alibi.services
|
package app.myzel394.alibi.services
|
||||||
|
|
||||||
import android.media.MediaRecorder
|
import app.myzel394.alibi.db.AppSettings
|
||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.helpers.BatchesFolder
|
||||||
import app.myzel394.alibi.db.AudioRecorderSettings
|
|
||||||
import app.myzel394.alibi.db.RecordingInformation
|
|
||||||
import app.myzel394.alibi.helpers.AudioRecorderExporter
|
|
||||||
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.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.ScheduledExecutorService
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
abstract class IntervalRecorderService : ExtraRecorderInformationService() {
|
abstract class IntervalRecorderService<I, B : BatchesFolder> :
|
||||||
private var job = SupervisorJob()
|
RecorderService() {
|
||||||
private var scope = CoroutineScope(Dispatchers.IO + job)
|
protected var counter = 0L
|
||||||
|
|
||||||
protected var counter = 0
|
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var settings: Settings? = null
|
// Tracks the index of the currently locked file
|
||||||
protected set
|
private var lockedIndex: Long? = null
|
||||||
|
|
||||||
|
lateinit var settings: AppSettings
|
||||||
|
|
||||||
private lateinit var cycleTimer: ScheduledExecutorService
|
private lateinit var cycleTimer: ScheduledExecutorService
|
||||||
|
|
||||||
protected val outputFolder: File
|
abstract var batchesFolder: B
|
||||||
get() = AudioRecorderExporter.getFolder(this)
|
|
||||||
|
|
||||||
fun getRecordingInformation(): RecordingInformation = RecordingInformation(
|
var onBatchesFolderNotAccessible: () -> Unit = {}
|
||||||
folderPath = outputFolder.absolutePath,
|
|
||||||
recordingStart = recordingStart,
|
abstract fun getRecordingInformation(): I
|
||||||
maxDuration = settings!!.maxDuration,
|
|
||||||
fileExtension = settings!!.fileExtension,
|
// When saving the recording, the files should be locked.
|
||||||
intervalDuration = settings!!.intervalDuration,
|
// This prevents the service from deleting the currently available files, so that
|
||||||
forceExactMaxDuration = settings!!.forceExactMaxDuration,
|
// they can be safely used to save the recording.
|
||||||
)
|
// Once finished, make sure to unlock the files using `unlockFiles`.
|
||||||
|
fun lockFiles() {
|
||||||
|
lockedIndex = counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlocks and deletes the files that were locked using `lockFiles`.
|
||||||
|
fun unlockFiles(cleanupFiles: Boolean = false) {
|
||||||
|
if (cleanupFiles) {
|
||||||
|
batchesFolder.deleteRecordings(0..<lockedIndex!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
lockedIndex = null
|
||||||
|
}
|
||||||
|
|
||||||
// Make overrideable
|
// Make overrideable
|
||||||
open fun startNewCycle() {
|
open fun startNewCycle() {
|
||||||
@ -48,11 +50,9 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
|
|||||||
private fun createTimer() {
|
private fun createTimer() {
|
||||||
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||||
it.scheduleAtFixedRate(
|
it.scheduleAtFixedRate(
|
||||||
{
|
::startNewCycle,
|
||||||
startNewCycle()
|
|
||||||
},
|
|
||||||
0,
|
0,
|
||||||
settings!!.intervalDuration,
|
settings.intervalDuration,
|
||||||
TimeUnit.MILLISECONDS
|
TimeUnit.MILLISECONDS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -61,82 +61,45 @@ abstract class IntervalRecorderService : ExtraRecorderInformationService() {
|
|||||||
override fun start() {
|
override fun start() {
|
||||||
super.start()
|
super.start()
|
||||||
|
|
||||||
outputFolder.mkdirs()
|
batchesFolder.initFolders()
|
||||||
|
|
||||||
scope.launch {
|
if (!batchesFolder.checkIfFolderIsAccessible()) {
|
||||||
dataStore.data.collectLatest { preferenceSettings ->
|
onBatchesFolderNotAccessible()
|
||||||
if (settings == null) {
|
|
||||||
settings = Settings.from(preferenceSettings.audioRecorderSettings)
|
|
||||||
|
|
||||||
createTimer()
|
throw AvoidErrorDialogError()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pause() {
|
override fun pause() {
|
||||||
|
super.pause()
|
||||||
cycleTimer.shutdown()
|
cycleTimer.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun resume() {
|
override fun resume() {
|
||||||
createTimer()
|
|
||||||
|
|
||||||
// We first want to start our timers, so the `ExtraRecorderInformationService` can fetch
|
|
||||||
// amplitudes
|
|
||||||
super.resume()
|
super.resume()
|
||||||
|
createTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override suspend fun stop() {
|
||||||
cycleTimer.shutdown()
|
cycleTimer.shutdown()
|
||||||
|
batchesFolder.cleanup()
|
||||||
|
super.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAllRecordings() {
|
||||||
|
batchesFolder.deleteRecordings()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteOldRecordings() {
|
private fun deleteOldRecordings() {
|
||||||
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
|
val timeMultiplier = settings.maxDuration / settings.intervalDuration
|
||||||
val earliestCounter = counter - timeMultiplier
|
val earliestCounter = Math.max(counter - timeMultiplier, lockedIndex ?: 0)
|
||||||
|
|
||||||
outputFolder.listFiles()?.forEach { file ->
|
if (earliestCounter <= 0) {
|
||||||
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
|
return
|
||||||
|
|
||||||
if (fileCounter < earliestCounter) {
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
data class Settings(
|
batchesFolder.deleteRecordings(0..earliestCounter)
|
||||||
val maxDuration: Long,
|
|
||||||
val intervalDuration: Long,
|
|
||||||
val forceExactMaxDuration: Boolean,
|
|
||||||
val bitRate: Int,
|
|
||||||
val samplingRate: Int,
|
|
||||||
val outputFormat: Int,
|
|
||||||
val encoder: Int,
|
|
||||||
) {
|
|
||||||
val fileExtension: String
|
|
||||||
get() = when (outputFormat) {
|
|
||||||
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
|
|
||||||
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
|
|
||||||
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
|
|
||||||
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
|
|
||||||
MediaRecorder.OutputFormat.WEBM -> "webm"
|
|
||||||
MediaRecorder.OutputFormat.AMR_NB -> "amr"
|
|
||||||
MediaRecorder.OutputFormat.AMR_WB -> "awb"
|
|
||||||
MediaRecorder.OutputFormat.OGG -> "ogg"
|
|
||||||
else -> "raw"
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
|
|
||||||
return Settings(
|
|
||||||
intervalDuration = audioRecorderSettings.intervalDuration,
|
|
||||||
bitRate = audioRecorderSettings.bitRate,
|
|
||||||
samplingRate = audioRecorderSettings.getSamplingRate(),
|
|
||||||
outputFormat = audioRecorderSettings.getOutputFormat(),
|
|
||||||
encoder = audioRecorderSettings.getEncoder(),
|
|
||||||
maxDuration = audioRecorderSettings.maxDuration,
|
|
||||||
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
10
app/src/main/java/app/myzel394/alibi/services/README.md
Normal file
10
app/src/main/java/app/myzel394/alibi/services/README.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# services
|
||||||
|
|
||||||
|
This folder contains all available services.
|
||||||
|
|
||||||
|
## VideoRecorderService
|
||||||
|
|
||||||
|
I found it a bit confusing on how to properly handle the services, so I made this diagram
|
||||||
|
to help me understand it better. I hope it helps you too.
|
||||||
|
|
||||||
|

|
@ -58,7 +58,7 @@ data class RecorderNotificationHelper(
|
|||||||
return PendingIntent.getService(
|
return PendingIntent.getService(
|
||||||
context,
|
context,
|
||||||
requestCode,
|
requestCode,
|
||||||
Intent(context, AudioRecorderService::class.java).apply {
|
Intent(context, context::class.java).apply {
|
||||||
action = "changeState"
|
action = "changeState"
|
||||||
putExtra("newState", newState.name)
|
putExtra("newState", newState.name)
|
||||||
},
|
},
|
||||||
@ -89,10 +89,24 @@ data class RecorderNotificationHelper(
|
|||||||
.setChronometerCountDown(false)
|
.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 {
|
fun buildStartingNotification(): Notification {
|
||||||
return createBaseNotification()
|
return createBaseNotification()
|
||||||
.setContentTitle(context.getString(R.string.ui_audioRecorder_state_recording_title))
|
.setContentTitle(
|
||||||
.setContentText(context.getString(R.string.ui_audioRecorder_state_recording_description))
|
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()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,41 +119,39 @@ data class RecorderNotificationHelper(
|
|||||||
Date.from(
|
Date.from(
|
||||||
Calendar
|
Calendar
|
||||||
.getInstance()
|
.getInstance()
|
||||||
.also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) }
|
.also { it.add(Calendar.SECOND, -recordingTime.toInt()) }
|
||||||
.toInstant()
|
.toInstant()
|
||||||
).time,
|
).time,
|
||||||
)
|
)
|
||||||
.addAction(
|
|
||||||
R.drawable.ic_cancel,
|
|
||||||
context.getString(R.string.ui_audioRecorder_action_delete_label),
|
|
||||||
getNotificationChangeStateIntent(RecorderState.IDLE, 1),
|
|
||||||
)
|
|
||||||
.addAction(
|
.addAction(
|
||||||
R.drawable.ic_pause,
|
R.drawable.ic_pause,
|
||||||
context.getString(R.string.ui_audioRecorder_action_pause_label),
|
context.getString(R.string.ui_recorder_action_pause_label),
|
||||||
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
|
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
|
||||||
)
|
)
|
||||||
.setContentTitle(
|
.setContentTitle(
|
||||||
details?.title
|
details?.title
|
||||||
?: context.getString(R.string.ui_audioRecorder_state_recording_title)
|
?: getStringForRecorder(
|
||||||
|
R.string.ui_audioRecorder_state_recording_title,
|
||||||
|
R.string.ui_videoRecorder_state_recording_title,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.setContentText(
|
.setContentText(
|
||||||
details?.description
|
details?.description
|
||||||
?: context.getString(R.string.ui_audioRecorder_state_recording_description)
|
?: context.getString(R.string.ui_recorder_state_recording_description)
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildPausedNotification(start: LocalDateTime): Notification {
|
fun buildPausedNotification(start: LocalDateTime): Notification {
|
||||||
return createBaseNotification()
|
return createBaseNotification()
|
||||||
.setContentTitle(context.getString(R.string.ui_audioRecorder_state_paused_title))
|
.setContentTitle(context.getString(R.string.ui_recorder_state_paused_title))
|
||||||
.setContentText(context.getString(R.string.ui_audioRecorder_state_paused_description))
|
.setContentText(context.getString(R.string.ui_recorder_state_paused_description))
|
||||||
.setOngoing(false)
|
.setOngoing(false)
|
||||||
.setUsesChronometer(false)
|
.setUsesChronometer(false)
|
||||||
.setWhen(Date.from(start.atZone(ZoneId.systemDefault()).toInstant()).time)
|
.setWhen(Date.from(start.atZone(ZoneId.systemDefault()).toInstant()).time)
|
||||||
.addAction(
|
.addAction(
|
||||||
R.drawable.ic_play,
|
R.drawable.ic_play,
|
||||||
context.getString(R.string.ui_audioRecorder_action_resume_label),
|
context.getString(R.string.ui_recorder_action_resume_label),
|
||||||
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
|
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
@ -2,14 +2,11 @@ package app.myzel394.alibi.services
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.lifecycle.LifecycleService
|
||||||
import app.myzel394.alibi.NotificationHelper
|
import app.myzel394.alibi.NotificationHelper
|
||||||
import app.myzel394.alibi.enums.RecorderState
|
import app.myzel394.alibi.enums.RecorderState
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||||
@ -20,31 +17,86 @@ import java.util.concurrent.ScheduledExecutorService
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
||||||
abstract class RecorderService : Service() {
|
abstract class RecorderService : LifecycleService() {
|
||||||
private val binder = RecorderBinder()
|
private val binder = RecorderBinder()
|
||||||
|
|
||||||
private var isPaused: Boolean = false
|
private var isPaused: Boolean = false
|
||||||
|
|
||||||
lateinit var recordingStart: LocalDateTime
|
lateinit var recordingStart: LocalDateTime
|
||||||
private set
|
private set
|
||||||
|
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
||||||
|
private var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
|
||||||
|
|
||||||
var state = RecorderState.IDLE
|
var state = RecorderState.IDLE
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var onStateChange: ((RecorderState) -> Unit)? = null
|
var onStateChange: ((RecorderState) -> Unit)? = null
|
||||||
|
var onError: () -> Unit = {}
|
||||||
|
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
||||||
|
|
||||||
var recordingTime = 0L
|
var recordingTime = 0L
|
||||||
private set
|
private set
|
||||||
private lateinit var recordingTimeTimer: ScheduledExecutorService
|
|
||||||
var onRecordingTimeChange: ((Long) -> Unit)? = null
|
|
||||||
var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
|
|
||||||
|
|
||||||
protected abstract fun start()
|
protected open fun start() {
|
||||||
protected abstract fun pause()
|
createRecordingTimeTimer()
|
||||||
protected abstract fun resume()
|
}
|
||||||
protected abstract fun stop()
|
|
||||||
|
|
||||||
override fun onBind(p0: Intent?): IBinder? = binder
|
protected open fun pause() {
|
||||||
|
isPaused = true
|
||||||
|
|
||||||
|
recordingTimeTimer.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun resume() {
|
||||||
|
createRecordingTimeTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open suspend fun stop() {
|
||||||
|
recordingTimeTimer.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun startForegroundService()
|
||||||
|
|
||||||
|
fun startRecording() {
|
||||||
|
recordingStart = LocalDateTime.now()
|
||||||
|
|
||||||
|
startForegroundService()
|
||||||
|
changeState(RecorderState.RECORDING)
|
||||||
|
|
||||||
|
try {
|
||||||
|
start()
|
||||||
|
} catch (error: RuntimeException) {
|
||||||
|
error.printStackTrace()
|
||||||
|
|
||||||
|
if (error !is AvoidErrorDialogError) {
|
||||||
|
onError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun stopRecording() {
|
||||||
|
changeState(RecorderState.STOPPED)
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pauseRecording() {
|
||||||
|
changeState(RecorderState.PAUSED)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumeRecording() {
|
||||||
|
changeState(RecorderState.RECORDING)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
NotificationManagerCompat.from(this)
|
||||||
|
.cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
super.onBind(intent)
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
@ -60,7 +112,7 @@ abstract class RecorderService : Service() {
|
|||||||
"changeState" -> {
|
"changeState" -> {
|
||||||
val newState = intent.getStringExtra("newState")?.let {
|
val newState = intent.getStringExtra("newState")?.let {
|
||||||
RecorderState.valueOf(it)
|
RecorderState.valueOf(it)
|
||||||
} ?: RecorderState.IDLE
|
} ?: RecorderState.STOPPED
|
||||||
changeState(newState)
|
changeState(newState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -76,16 +128,19 @@ abstract class RecorderService : Service() {
|
|||||||
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
|
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
|
||||||
it.scheduleAtFixedRate(
|
it.scheduleAtFixedRate(
|
||||||
{
|
{
|
||||||
recordingTime += 1000
|
recordingTime += 1
|
||||||
onRecordingTimeChange?.invoke(recordingTime)
|
onRecordingTimeChange?.invoke(recordingTime)
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
1000,
|
1,
|
||||||
TimeUnit.MILLISECONDS
|
TimeUnit.SECONDS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Used to change the state of the service
|
||||||
|
// will internally call start() / pause() / resume() / stop()
|
||||||
|
// Immediately after creating the service make sure to call `changeState(RecorderState.RECORDING)`
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun changeState(newState: RecorderState) {
|
fun changeState(newState: RecorderState) {
|
||||||
if (state == newState) {
|
if (state == newState) {
|
||||||
@ -98,30 +153,16 @@ abstract class RecorderService : Service() {
|
|||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
resume()
|
resume()
|
||||||
isPaused = false
|
isPaused = false
|
||||||
} else {
|
|
||||||
start()
|
|
||||||
}
|
}
|
||||||
|
// `start` is handled by `startRecording`
|
||||||
}
|
}
|
||||||
|
|
||||||
RecorderState.PAUSED -> {
|
RecorderState.PAUSED -> pause()
|
||||||
pause()
|
|
||||||
isPaused = true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (newState) {
|
// Update notification
|
||||||
RecorderState.RECORDING -> {
|
|
||||||
createRecordingTimeTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
RecorderState.PAUSED, RecorderState.IDLE -> {
|
|
||||||
recordingTimeTimer.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
arrayOf(
|
arrayOf(
|
||||||
RecorderState.RECORDING,
|
RecorderState.RECORDING,
|
||||||
@ -135,45 +176,14 @@ abstract class RecorderService : Service() {
|
|||||||
notification
|
notification
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onStateChange?.invoke(newState)
|
onStateChange?.invoke(newState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must be immediately called after creating the service!
|
protected fun getNotificationHelper(): RecorderNotificationHelper {
|
||||||
fun startRecording() {
|
|
||||||
recordingStart = LocalDateTime.now()
|
|
||||||
|
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Start
|
|
||||||
changeState(RecorderState.RECORDING)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
|
|
||||||
stop()
|
|
||||||
changeState(RecorderState.IDLE)
|
|
||||||
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
NotificationManagerCompat.from(this)
|
|
||||||
.cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getNotificationHelper(): RecorderNotificationHelper {
|
|
||||||
return RecorderNotificationHelper(this, notificationDetails)
|
return RecorderNotificationHelper(this, notificationDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun buildNotification(): Notification {
|
private fun buildNotification(): Notification {
|
||||||
val notificationHelper = getNotificationHelper()
|
val notificationHelper = getNotificationHelper()
|
||||||
|
|
||||||
@ -191,4 +201,9 @@ abstract class RecorderService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,12 +2,25 @@ package app.myzel394.alibi.ui
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import java.io.File
|
import java.util.Base64
|
||||||
|
|
||||||
val BIG_PRIMARY_BUTTON_SIZE = 64.dp
|
val BIG_PRIMARY_BUTTON_SIZE = 64.dp
|
||||||
|
val BIG_PRIMARY_BUTTON_MAX_WIDTH = 450.dp
|
||||||
|
|
||||||
|
val SHEET_BOTTOM_OFFSET = 24.dp
|
||||||
val MAX_AMPLITUDE = 20000
|
val MAX_AMPLITUDE = 20000
|
||||||
val SUPPORTS_DARK_MODE_NATIVELY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
val SUPPORTS_DARK_MODE_NATIVELY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||||
val RECORDER_SUBFOLDER_NAME = ".recordings"
|
|
||||||
|
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.
|
// You are not allowed to change the constants below.
|
||||||
// If you do so, you will be blocked on GitHub.
|
// If you do so, you will be blocked on GitHub.
|
||||||
@ -51,3 +64,17 @@ val CRYPTO_DONATIONS = mapOf(
|
|||||||
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
|
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
|
||||||
"Filecoin" to "f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq",
|
"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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -14,6 +14,7 @@ import androidx.compose.runtime.DisposableEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@ -21,17 +22,20 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.dataStore
|
||||||
import app.myzel394.alibi.ui.enums.Screen
|
import app.myzel394.alibi.ui.enums.Screen
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
|
import app.myzel394.alibi.ui.models.VideoRecorderModel
|
||||||
import app.myzel394.alibi.ui.screens.AboutScreen
|
import app.myzel394.alibi.ui.screens.AboutScreen
|
||||||
import app.myzel394.alibi.ui.screens.AudioRecorderScreen
|
|
||||||
import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen
|
import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen
|
||||||
|
import app.myzel394.alibi.ui.screens.RecorderScreen
|
||||||
import app.myzel394.alibi.ui.screens.SettingsScreen
|
import app.myzel394.alibi.ui.screens.SettingsScreen
|
||||||
import app.myzel394.alibi.ui.screens.WelcomeScreen
|
import app.myzel394.alibi.ui.screens.WelcomeScreen
|
||||||
|
|
||||||
const val SCALE_IN = 1.25f
|
const val SCALE_IN = 1.25f
|
||||||
|
const val DEBUG_SKIP_WELCOME = false;
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Navigation(
|
fun Navigation(
|
||||||
audioRecorder: AudioRecorderModel = viewModel()
|
audioRecorder: AudioRecorderModel = viewModel(),
|
||||||
|
videoRecorder: VideoRecorderModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@ -43,9 +47,11 @@ fun Navigation(
|
|||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
audioRecorder.bindToService(context)
|
audioRecorder.bindToService(context)
|
||||||
|
videoRecorder.bindToService(context)
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
audioRecorder.unbindFromService(context)
|
audioRecorder.unbindFromService(context)
|
||||||
|
videoRecorder.unbindFromService(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,10 +59,18 @@ fun Navigation(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(MaterialTheme.colorScheme.background),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = if (settings.hasSeenOnboarding) Screen.AudioRecorder.route else Screen.Welcome.route,
|
startDestination = if (settings.hasSeenOnboarding || DEBUG_SKIP_WELCOME) Screen.AudioRecorder.route else Screen.Welcome.route,
|
||||||
) {
|
) {
|
||||||
composable(Screen.Welcome.route) {
|
composable(Screen.Welcome.route) {
|
||||||
WelcomeScreen(navController = navController)
|
WelcomeScreen(
|
||||||
|
onNavigateToAudioRecorderScreen = {
|
||||||
|
val mainHandler = ContextCompat.getMainExecutor(context)
|
||||||
|
|
||||||
|
mainHandler.execute {
|
||||||
|
navController.navigate(Screen.AudioRecorder.route)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
Screen.AudioRecorder.route,
|
Screen.AudioRecorder.route,
|
||||||
@ -70,9 +84,13 @@ fun Navigation(
|
|||||||
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
|
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
AudioRecorderScreen(
|
RecorderScreen(
|
||||||
navController = navController,
|
onNavigateToSettingsScreen = {
|
||||||
|
navController.navigate(Screen.Settings.route)
|
||||||
|
},
|
||||||
audioRecorder = audioRecorder,
|
audioRecorder = audioRecorder,
|
||||||
|
videoRecorder = videoRecorder,
|
||||||
|
settings = settings,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@ -85,8 +103,13 @@ fun Navigation(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
navController = navController,
|
onBackNavigate = navController::popBackStack,
|
||||||
|
onNavigateToCustomRecordingNotifications = {
|
||||||
|
navController.navigate(Screen.CustomRecordingNotifications.route)
|
||||||
|
},
|
||||||
|
onNavigateToAboutScreen = { navController.navigate(Screen.About.route) },
|
||||||
audioRecorder = audioRecorder,
|
audioRecorder = audioRecorder,
|
||||||
|
videoRecorder = videoRecorder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@ -103,7 +126,7 @@ fun Navigation(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
CustomRecordingNotificationsScreen(
|
CustomRecordingNotificationsScreen(
|
||||||
navController = navController,
|
onBackNavigate = navController::popBackStack
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
@ -116,7 +139,7 @@ fun Navigation(
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
AboutScreen(
|
AboutScreen(
|
||||||
navController = navController,
|
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.
|
@ -33,8 +33,10 @@ import androidx.compose.material.icons.filled.CurrencyYuan
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -130,11 +132,11 @@ fun DonationsTile() {
|
|||||||
Column {
|
Column {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
Button(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
uriHandler.openUri(GITHUB_SPONSORS_URL)
|
uriHandler.openUri(GITHUB_SPONSORS_URL)
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
|
@ -16,6 +16,7 @@ import androidx.compose.material3.ButtonDefaults
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -62,12 +63,11 @@ fun GPGKeyOverview() {
|
|||||||
)
|
)
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
)
|
)
|
||||||
Button(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val clip = ClipData.newPlainText("text", PUBLIC_KEY)
|
val clip = ClipData.newPlainText("text", PUBLIC_KEY)
|
||||||
clipboardManager.setPrimaryClip(clip)
|
clipboardManager.setPrimaryClip(clip)
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
|
@ -1,45 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
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.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.unit.dp
|
|
||||||
import app.myzel394.alibi.ui.components.atoms.Pulsating
|
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.formatDuration
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RecordingTime(
|
|
||||||
time: Long,
|
|
||||||
) {
|
|
||||||
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(time),
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
|
||||||
|
|
||||||
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.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.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SaveButton(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onSave: () -> Unit,
|
|
||||||
) {
|
|
||||||
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
modifier = Modifier
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
}
|
|
||||||
.then(modifier),
|
|
||||||
onClick = onSave,
|
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Save,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(ButtonDefaults.IconSize)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(ButtonDefaults.IconSpacing))
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,186 +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.material3.rememberStandardBottomSheetState
|
|
||||||
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.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.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.NotificationHelper
|
|
||||||
import app.myzel394.alibi.R
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.helpers.AudioRecorderExporter
|
|
||||||
import app.myzel394.alibi.services.RecorderNotificationHelper
|
|
||||||
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 kotlinx.coroutines.flow.last
|
|
||||||
import kotlinx.coroutines.flow.lastOrNull
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.FormatStyle
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StartRecording(
|
|
||||||
audioRecorder: AudioRecorderModel,
|
|
||||||
// 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,
|
|
||||||
) {
|
|
||||||
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.notificationDetails = appSettings.notificationSettings.let {
|
|
||||||
if (it == null)
|
|
||||||
null
|
|
||||||
else
|
|
||||||
RecorderNotificationHelper.NotificationDetails.fromNotificationSettings(
|
|
||||||
context,
|
|
||||||
it
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AudioRecorderExporter.clearAllRecordings(context)
|
|
||||||
|
|
||||||
audioRecorder.startRecording(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = {
|
|
||||||
startRecording = true
|
|
||||||
},
|
|
||||||
) { 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 (appSettings.lastRecording?.hasRecordingsAvailable == true) {
|
|
||||||
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(appSettings.lastRecording.recordingStart),
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
},
|
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
onClick = onSaveLastRecording,
|
|
||||||
) {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,150 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.organisms
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.expandHorizontally
|
|
||||||
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.width
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
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.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import app.myzel394.alibi.dataStore
|
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.helpers.AudioRecorderExporter
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.DeleteButton
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.PauseResumeButton
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RecordingTime
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveButton
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.MicrophoneSelection
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.molecules.MicrophoneStatus
|
|
||||||
import app.myzel394.alibi.ui.effects.rememberPrevious
|
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
|
||||||
import app.myzel394.alibi.ui.utils.KeepScreenOn
|
|
||||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import java.time.LocalDateTime
|
|
||||||
|
|
||||||
@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,
|
|
||||||
) {
|
|
||||||
RecordingTime(audioRecorder.recordingTime!!)
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
DeleteButton(
|
|
||||||
onDelete = {
|
|
||||||
audioRecorder.stopRecording(context)
|
|
||||||
|
|
||||||
AudioRecorderExporter.clearAllRecordings(context)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
PauseResumeButton(
|
|
||||||
isPaused = audioRecorder.isPaused,
|
|
||||||
onChange = {
|
|
||||||
if (audioRecorder.isPaused) {
|
|
||||||
audioRecorder.resumeRecording()
|
|
||||||
} else {
|
|
||||||
audioRecorder.pauseRecording()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
SaveButton(
|
|
||||||
onSave = {
|
|
||||||
runCatching {
|
|
||||||
audioRecorder.stopRecording(context)
|
|
||||||
}
|
|
||||||
audioRecorder.onRecordingSave()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MicrophoneStatus(audioRecorder)
|
|
||||||
}
|
|
||||||
}
|
|
@ -19,9 +19,11 @@ import androidx.compose.material.icons.filled.Edit
|
|||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -82,10 +84,7 @@ fun LandingElement(
|
|||||||
stringResource(R.string.ui_settings_customNotifications_landing_description),
|
stringResource(R.string.ui_settings_customNotifications_landing_description),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
Button(
|
FilledTonalButton(onClick = onOpenEditor) {
|
||||||
onClick = onOpenEditor,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(),
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Edit,
|
Icons.Default.Edit,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@ -100,9 +99,8 @@ fun LandingElement(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button(
|
TextButton(
|
||||||
onClick = context::openNotificationsSettings,
|
onClick = context::openNotificationsSettings,
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.ui_settings_customNotifications_landing_help_hideNotifications),
|
stringResource(R.string.ui_settings_customNotifications_landing_help_hideNotifications),
|
||||||
|
@ -1,18 +1,12 @@
|
|||||||
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
|
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@ -20,7 +14,6 @@ import androidx.compose.material.icons.filled.Circle
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -31,12 +24,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.graphics.toArgb
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
@ -45,10 +34,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import app.myzel394.alibi.R
|
import app.myzel394.alibi.R
|
||||||
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.PreviewIcon
|
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.PreviewIcon
|
||||||
import app.myzel394.alibi.ui.effects.rememberForceUpdate
|
import app.myzel394.alibi.ui.effects.rememberForceUpdate
|
||||||
import com.maxkeppeler.sheets.input.models.InputText
|
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.Period
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EditNotificationInput(
|
fun EditNotificationInput(
|
||||||
@ -167,13 +154,13 @@ fun EditNotificationInput(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.ui_audioRecorder_action_delete_label),
|
stringResource(R.string.ui_recorder_action_delete_label),
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.ui_audioRecorder_action_pause_label),
|
stringResource(R.string.ui_recorder_action_pause_label),
|
||||||
color = MaterialTheme.colorScheme.secondary,
|
color = MaterialTheme.colorScheme.secondary,
|
||||||
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
@ -90,7 +90,7 @@ fun NotificationEditor(
|
|||||||
} else {
|
} else {
|
||||||
val defaultTitle = stringResource(R.string.ui_audioRecorder_state_recording_title)
|
val defaultTitle = stringResource(R.string.ui_audioRecorder_state_recording_title)
|
||||||
val defaultDescription =
|
val defaultDescription =
|
||||||
stringResource(R.string.ui_audioRecorder_state_recording_description)
|
stringResource(R.string.ui_recorder_state_recording_description)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
notificationModel.initialize(
|
notificationModel.initialize(
|
||||||
@ -178,6 +178,7 @@ fun NotificationEditor(
|
|||||||
notificationModel.asNotificationSettings()
|
notificationModel.asNotificationSettings()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = HORIZONTAL_PADDING)
|
.padding(horizontal = HORIZONTAL_PADDING)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -29,7 +29,7 @@ fun AudioVisualizer(
|
|||||||
val width = this.size.width
|
val width = this.size.width
|
||||||
val boxWidth = width / amplitudes.size
|
val boxWidth = width / amplitudes.size
|
||||||
|
|
||||||
amplitudes.forEachIndexed {index, amplitude ->
|
amplitudes.forEachIndexed { index, amplitude ->
|
||||||
val x = boxWidth * index
|
val x = boxWidth * index
|
||||||
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
||||||
val boxHeight = height * amplitudePercentage
|
val boxHeight = height * amplitudePercentage
|
@ -0,0 +1,39 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BatchesInaccessibleDialog(
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Error,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.ui_recorder_error_recording_title))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.ui_recorder_error_batchesInaccessible_description))
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onClose) {
|
||||||
|
Text(stringResource(R.string.dialog_close_neutral_label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.ripple
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun BigButton(
|
||||||
|
label: String,
|
||||||
|
icon: ImageVector,
|
||||||
|
description: String? = null,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit = {},
|
||||||
|
isBig: Boolean? = null,
|
||||||
|
) {
|
||||||
|
val orientation = LocalConfiguration.current.orientation
|
||||||
|
|
||||||
|
BoxWithConstraints {
|
||||||
|
val isLarge = isBig
|
||||||
|
?: (maxWidth > 250.dp && maxHeight > 600.dp && orientation == Configuration.ORIENTATION_PORTRAIT)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(if (isLarge) 250.dp else 190.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
}
|
||||||
|
.combinedClickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = ripple(color = MaterialTheme.colorScheme.primary),
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(if (isLarge) 80.dp else 60.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
if (description != null) {
|
||||||
|
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import app.myzel394.alibi.ui.utils.getCameraProvider
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CameraPreview(
|
||||||
|
modifier: Modifier,
|
||||||
|
cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
// Video preview
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
val previewView = PreviewView(context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val previewUseCase = Preview.Builder()
|
||||||
|
.build()
|
||||||
|
.also { it.setSurfaceProvider(previewView.surfaceProvider) }
|
||||||
|
|
||||||
|
coroutineScope.launch {
|
||||||
|
val cameraProvider = context.getCameraProvider()
|
||||||
|
try {
|
||||||
|
cameraProvider.unbindAll()
|
||||||
|
cameraProvider.bindToLifecycle(
|
||||||
|
lifecycleOwner,
|
||||||
|
cameraSelector,
|
||||||
|
previewUseCase
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Log.e("CameraPreview", "Use case binding failed", ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previewView
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,103 @@
|
|||||||
|
import androidx.camera.core.ExperimentalLensFacing
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Camera
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.QuestionMark
|
||||||
|
import androidx.compose.material.icons.filled.Videocam
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.ui.utils.CameraInfo
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CameraSelectionButton(
|
||||||
|
cameraID: CameraInfo.Lens,
|
||||||
|
selected: Boolean,
|
||||||
|
onSelected: () -> Unit,
|
||||||
|
label: String,
|
||||||
|
description: String? = null,
|
||||||
|
) {
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
targetValue = if (selected) MaterialTheme.colorScheme.secondaryContainer.copy(
|
||||||
|
alpha = 0.2f
|
||||||
|
) else Color.Transparent,
|
||||||
|
// Make animation about 0.5x faster than default
|
||||||
|
animationSpec = spring(
|
||||||
|
stiffness = Spring.StiffnessLow,
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
),
|
||||||
|
label = "backgroundColor"
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.clickable(onClick = onSelected)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(vertical = 8.dp, horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selected,
|
||||||
|
onClick = onSelected,
|
||||||
|
)
|
||||||
|
if (description == null) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
CAMERA_LENS_ICON_MAP[cameraID]!!,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val CAMERA_LENS_ICON_MAP = mapOf(
|
||||||
|
CameraInfo.Lens.BACK to Icons.Default.Camera,
|
||||||
|
CameraInfo.Lens.FRONT to Icons.Default.Person,
|
||||||
|
CameraInfo.Lens.EXTERNAL to Icons.Default.Videocam,
|
||||||
|
CameraInfo.Lens.UNKNOWN to Icons.Default.QuestionMark,
|
||||||
|
)
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -11,6 +11,7 @@ import androidx.compose.material3.Button
|
|||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -28,10 +29,10 @@ fun ConfirmDeletionDialog(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_title))
|
Text(stringResource(R.string.ui_recorder_action_delete_confirm_title))
|
||||||
},
|
},
|
||||||
text = {
|
text = {
|
||||||
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_message))
|
Text(stringResource(R.string.ui_recorder_action_delete_confirm_message))
|
||||||
},
|
},
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
@ -40,12 +41,13 @@ fun ConfirmDeletionDialog(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
|
val label = stringResource(R.string.ui_recorder_action_delete_label)
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = label
|
contentDescription = label
|
||||||
},
|
},
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
onClick = {
|
onClick = {
|
||||||
onConfirm()
|
onConfirm()
|
||||||
},
|
},
|
||||||
@ -61,15 +63,15 @@ fun ConfirmDeletionDialog(
|
|||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
val label = stringResource(R.string.dialog_close_cancel_label)
|
val label = stringResource(R.string.dialog_close_cancel_label)
|
||||||
Button(
|
TextButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = label
|
contentDescription = label
|
||||||
},
|
},
|
||||||
|
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||||
onClick = {
|
onClick = {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Cancel,
|
Icons.Default.Cancel,
|
@ -1,15 +1,10 @@
|
|||||||
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
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -39,8 +34,8 @@ fun DeleteButton(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
|
val label = stringResource(R.string.ui_recorder_action_delete_label)
|
||||||
Button(
|
TextButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = label
|
contentDescription = label
|
||||||
@ -49,7 +44,6 @@ fun DeleteButton(
|
|||||||
onClick = {
|
onClick = {
|
||||||
showDeleteDialog = true
|
showDeleteDialog = true
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.MicOff
|
import androidx.compose.material.icons.filled.MicOff
|
@ -1,7 +1,6 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.MicOff
|
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
@ -1,20 +1,14 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.MicExternalOn
|
import androidx.compose.material.icons.filled.MicExternalOn
|
||||||
import androidx.compose.material.icons.filled.Warning
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@ -56,6 +50,7 @@ fun MicrophoneSelectionButton(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(64.dp),
|
.height(64.dp),
|
||||||
colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(),
|
colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(),
|
||||||
|
contentPadding = if (selected) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.TextButtonContentPadding,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
@ -1,14 +1,10 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
import android.R
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.BluetoothAudio
|
import androidx.compose.material.icons.filled.BluetoothAudio
|
||||||
import androidx.compose.material.icons.filled.Memory
|
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.MicExternalOn
|
import androidx.compose.material.icons.filled.MicExternalOn
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.material.icons.filled.Smartphone
|
import androidx.compose.material.icons.filled.Smartphone
|
||||||
import androidx.compose.material.icons.filled.Warning
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
@ -1,12 +1,10 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Pause
|
import androidx.compose.material.icons.filled.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.LargeFloatingActionButton
|
|
||||||
import androidx.compose.material3.SmallFloatingActionButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -20,8 +18,8 @@ fun PauseResumeButton(
|
|||||||
isPaused: Boolean,
|
isPaused: Boolean,
|
||||||
onChange: () -> Unit,
|
onChange: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
|
val pauseLabel = stringResource(R.string.ui_recorder_action_pause_label)
|
||||||
val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
|
val resumeLabel = stringResource(R.string.ui_recorder_action_resume_label)
|
||||||
|
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
@ -1,16 +1,19 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
|
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
|
||||||
|
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.gestures.rememberTransformableState
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.gestures.transformable
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@ -19,7 +22,6 @@ import androidx.compose.ui.graphics.drawscope.translate
|
|||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.myzel394.alibi.services.RecorderService
|
|
||||||
import app.myzel394.alibi.ui.MAX_AMPLITUDE
|
import app.myzel394.alibi.ui.MAX_AMPLITUDE
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
import app.myzel394.alibi.ui.utils.clamp
|
import app.myzel394.alibi.ui.utils.clamp
|
||||||
@ -35,6 +37,7 @@ private const val GROW_END = BOX_DIFF * 4
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RealtimeAudioVisualizer(
|
fun RealtimeAudioVisualizer(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
audioRecorder: AudioRecorderModel,
|
audioRecorder: AudioRecorderModel,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -63,17 +66,28 @@ fun RealtimeAudioVisualizer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val configuration = LocalConfiguration.current
|
val configuration = LocalConfiguration.current
|
||||||
val screenWidth = with(LocalDensity.current) { configuration.screenWidthDp.dp.toPx() }
|
// Use greater value of width and height to make sure the amplitudes are shown
|
||||||
|
// when the user rotates the device
|
||||||
LaunchedEffect(screenWidth) {
|
val availableSpace = with(LocalDensity.current) {
|
||||||
// Add 1 to allow the visualizer to overflow the screen
|
Math.max(
|
||||||
audioRecorder.setMaxAmplitudesAmount(ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1)
|
configuration.screenWidthDp.dp.toPx(),
|
||||||
|
configuration.screenHeightDp.dp.toPx()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(availableSpace) {
|
||||||
|
// Add 1 to allow the visualizer to overflow the screen
|
||||||
|
audioRecorder.setMaxAmplitudesAmount(ceil(availableSpace.toInt() / BOX_DIFF).toInt() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
val transformState = rememberTransformableState { zoomChange, _, _ ->
|
||||||
|
scale *= zoomChange
|
||||||
|
}
|
||||||
|
val amplitudePercentageModifier = MAX_AMPLITUDE * (1 / scale)
|
||||||
|
|
||||||
Canvas(
|
Canvas(
|
||||||
modifier = Modifier
|
modifier = modifier.transformable(transformState),
|
||||||
.fillMaxWidth()
|
|
||||||
.height(300.dp),
|
|
||||||
) {
|
) {
|
||||||
val height = this.size.height / 2f
|
val height = this.size.height / 2f
|
||||||
val width = this.size.width
|
val width = this.size.width
|
||||||
@ -87,7 +101,8 @@ fun RealtimeAudioVisualizer(
|
|||||||
val horizontalProgress = (
|
val horizontalProgress = (
|
||||||
clamp(horizontalValue, GROW_START, GROW_END)
|
clamp(horizontalValue, GROW_START, GROW_END)
|
||||||
- GROW_START) / (GROW_END - GROW_START)
|
- GROW_START) / (GROW_END - GROW_START)
|
||||||
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
|
val amplitudePercentage =
|
||||||
|
(amplitude.toFloat() / amplitudePercentageModifier).coerceAtMost(1f)
|
||||||
val boxHeight =
|
val boxHeight =
|
||||||
(height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
|
(height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
|
||||||
|
|
@ -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]!!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,32 +1,30 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
|
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -38,12 +36,14 @@ import androidx.compose.ui.unit.dp
|
|||||||
import app.myzel394.alibi.R
|
import app.myzel394.alibi.R
|
||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.dataStore
|
||||||
import app.myzel394.alibi.db.AppSettings
|
import app.myzel394.alibi.db.AppSettings
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneSelectionButton
|
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneTypeInfo
|
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.MessageBox
|
||||||
import app.myzel394.alibi.ui.components.atoms.MessageType
|
import app.myzel394.alibi.ui.components.atoms.MessageType
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -76,17 +76,23 @@ fun MicrophoneSelection(
|
|||||||
visibleMicrophones
|
visibleMicrophones
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
fun hideSheet() {
|
||||||
|
scope.launch {
|
||||||
|
sheetState.hide()
|
||||||
|
showSelection = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showSelection) {
|
if (showSelection) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = {
|
onDismissRequest = ::hideSheet,
|
||||||
showSelection = false
|
|
||||||
},
|
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(bottom = 24.dp),
|
.padding(bottom = SHEET_BOTTOM_OFFSET),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(48.dp),
|
verticalArrangement = Arrangement.spacedBy(48.dp),
|
||||||
) {
|
) {
|
||||||
@ -117,7 +123,7 @@ fun MicrophoneSelection(
|
|||||||
selectedAsFallback = isTryingToReconnect,
|
selectedAsFallback = isTryingToReconnect,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
audioRecorder.changeMicrophone(null)
|
audioRecorder.changeMicrophone(null)
|
||||||
showSelection = false
|
hideSheet()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -131,7 +137,7 @@ fun MicrophoneSelection(
|
|||||||
disabled = isTryingToReconnect && microphone == audioRecorder.selectedMicrophone,
|
disabled = isTryingToReconnect && microphone == audioRecorder.selectedMicrophone,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
audioRecorder.changeMicrophone(microphone)
|
audioRecorder.changeMicrophone(microphone)
|
||||||
showSelection = false
|
hideSheet()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -143,7 +149,7 @@ fun MicrophoneSelection(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.padding(vertical = 32.dp),
|
modifier = Modifier.padding(vertical = 32.dp),
|
||||||
) {
|
) {
|
||||||
Divider(
|
HorizontalDivider(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
)
|
)
|
||||||
@ -153,7 +159,7 @@ fun MicrophoneSelection(
|
|||||||
color = MaterialTheme.colorScheme.tertiary,
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Divider(
|
HorizontalDivider(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f),
|
.weight(1f),
|
||||||
)
|
)
|
||||||
@ -168,7 +174,7 @@ fun MicrophoneSelection(
|
|||||||
selected = audioRecorder.selectedMicrophone == microphone,
|
selected = audioRecorder.selectedMicrophone == microphone,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
audioRecorder.changeMicrophone(microphone)
|
audioRecorder.changeMicrophone(microphone)
|
||||||
showSelection = false
|
hideSheet()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -182,11 +188,14 @@ fun MicrophoneSelection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shownMicrophones.isNotEmpty() || (settings.audioRecorderSettings.showAllMicrophones && hiddenMicrophones.isNotEmpty())) {
|
if (shownMicrophones.isNotEmpty() || (settings.audioRecorderSettings.showAllMicrophones && hiddenMicrophones.isNotEmpty())) {
|
||||||
Button(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
showSelection = true
|
scope.launch {
|
||||||
|
showSelection = true
|
||||||
|
sheetState.show()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
|
||||||
) {
|
) {
|
||||||
MicrophoneTypeInfo(
|
MicrophoneTypeInfo(
|
||||||
type = audioRecorder.selectedMicrophone?.type
|
type = audioRecorder.selectedMicrophone?.type
|
@ -1,21 +1,15 @@
|
|||||||
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
|
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneDisconnectedDialog
|
||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneReconnectedDialog
|
||||||
import app.myzel394.alibi.db.AppSettings
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneDisconnectedDialog
|
|
||||||
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.MicrophoneReconnectedDialog
|
|
||||||
import app.myzel394.alibi.ui.effects.rememberPrevious
|
import app.myzel394.alibi.ui.effects.rememberPrevious
|
||||||
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
import app.myzel394.alibi.ui.models.AudioRecorderModel
|
||||||
import app.myzel394.alibi.ui.utils.MicrophoneInfo
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MicrophoneStatus(
|
fun MicrophoneStatus(
|
@ -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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@ -31,7 +31,7 @@ import app.myzel394.alibi.ui.enums.Screen
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AboutTile(
|
fun AboutTile(
|
||||||
navController: NavController,
|
onNavigateToAboutScreen: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val label = stringResource(R.string.ui_about_title)
|
val label = stringResource(R.string.ui_about_title)
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ fun AboutTile(
|
|||||||
contentDescription = label
|
contentDescription = label
|
||||||
}
|
}
|
||||||
.clickable {
|
.clickable {
|
||||||
navController.navigate(Screen.About.route)
|
onNavigateToAboutScreen()
|
||||||
}
|
}
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
@ -1,8 +1,7 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Mic
|
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -11,7 +10,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -36,7 +34,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BitrateTile(
|
fun AudioRecorderBitrateTile(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -79,11 +77,11 @@ fun BitrateTile(
|
|||||||
val bitRate = text?.toIntOrNull()
|
val bitRate = text?.toIntOrNull()
|
||||||
|
|
||||||
if (bitRate == null) {
|
if (bitRate == null) {
|
||||||
ValidationResult.Invalid(notNumberLabel)
|
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bitRate !in 1..320) {
|
if (bitRate !in 1..320) {
|
||||||
ValidationResult.Invalid(notInRangeLabel)
|
return@InputTextField ValidationResult.Invalid(notInRangeLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidationResult.Valid
|
ValidationResult.Valid
|
||||||
@ -92,7 +90,9 @@ fun BitrateTile(
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
) { result ->
|
) { result ->
|
||||||
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException("Bitrate is null")
|
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException(
|
||||||
|
"Bitrate is null"
|
||||||
|
)
|
||||||
|
|
||||||
updateValue(bitRate * 1000)
|
updateValue(bitRate * 1000)
|
||||||
}
|
}
|
||||||
@ -126,7 +126,7 @@ fun BitrateTile(
|
|||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_BITRATE_VALUES,
|
items = AudioRecorderSettings.EXAMPLE_BITRATE_VALUES,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) {bitRate ->
|
) { bitRate ->
|
||||||
Text(
|
Text(
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.format_kbps,
|
R.string.format_kbps,
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@ -12,7 +12,6 @@ import androidx.compose.material3.SnackbarDuration
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -33,7 +32,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun EncoderTile(
|
fun AudioRecorderEncoderTile(
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
) {
|
) {
|
@ -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.Icons
|
||||||
import androidx.compose.material.icons.filled.AudioFile
|
import androidx.compose.material.icons.filled.AudioFile
|
||||||
@ -29,7 +29,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OutputFormatTile(
|
fun AudioRecorderOutputFormatTile(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
@ -1,9 +1,8 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||||
import androidx.compose.material.icons.filled.Tune
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -11,7 +10,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -36,7 +34,7 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SamplingRateTile(
|
fun AudioRecorderSamplingRateTile(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -60,7 +58,8 @@ fun SamplingRateTile(
|
|||||||
header = Header.Default(
|
header = Header.Default(
|
||||||
title = stringResource(R.string.ui_settings_option_samplingRate_title),
|
title = stringResource(R.string.ui_settings_option_samplingRate_title),
|
||||||
icon = IconSource(
|
icon = IconSource(
|
||||||
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked).asPainterResource(),
|
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked)
|
||||||
|
.asPainterResource(),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -79,11 +78,11 @@ fun SamplingRateTile(
|
|||||||
val samplingRate = text?.toIntOrNull()
|
val samplingRate = text?.toIntOrNull()
|
||||||
|
|
||||||
if (samplingRate == null) {
|
if (samplingRate == null) {
|
||||||
ValidationResult.Invalid(notNumberLabel)
|
return@InputTextField ValidationResult.Invalid(notNumberLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (samplingRate!! <= 1000) {
|
if (samplingRate <= 1000) {
|
||||||
ValidationResult.Invalid(mustBeGreaterThanLabel)
|
return@InputTextField ValidationResult.Invalid(mustBeGreaterThanLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidationResult.Valid
|
ValidationResult.Valid
|
||||||
@ -92,7 +91,8 @@ fun SamplingRateTile(
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
) { result ->
|
) { result ->
|
||||||
val samplingRate = result.getString("samplingRate")?.toIntOrNull() ?: throw IllegalStateException("SamplingRate is null")
|
val samplingRate = result.getString("samplingRate")?.toIntOrNull()
|
||||||
|
?: throw IllegalStateException("SamplingRate is null")
|
||||||
|
|
||||||
updateValue(samplingRate)
|
updateValue(samplingRate)
|
||||||
}
|
}
|
||||||
@ -115,7 +115,8 @@ fun SamplingRateTile(
|
|||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
(settings.audioRecorderSettings.samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
(settings.audioRecorderSettings.samplingRate
|
||||||
|
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -123,9 +124,10 @@ fun SamplingRateTile(
|
|||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_SAMPLING_RATE,
|
items = AudioRecorderSettings.EXAMPLE_SAMPLING_RATE,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) {samplingRate ->
|
) { samplingRate ->
|
||||||
Text(
|
Text(
|
||||||
(samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
(samplingRate
|
||||||
|
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,12 +1,10 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.GraphicEq
|
|
||||||
import androidx.compose.material.icons.filled.MicExternalOn
|
import androidx.compose.material.icons.filled.MicExternalOn
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -14,12 +12,11 @@ import app.myzel394.alibi.R
|
|||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.dataStore
|
||||||
import app.myzel394.alibi.db.AppSettings
|
import app.myzel394.alibi.db.AppSettings
|
||||||
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
import app.myzel394.alibi.ui.components.atoms.SettingsTile
|
||||||
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShowAllMicrophonesTile(
|
fun AudioRecorderShowAllMicrophonesTile(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@ -21,7 +21,7 @@ import app.myzel394.alibi.ui.enums.Screen
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CustomNotificationTile(
|
fun CustomNotificationTile(
|
||||||
navController: NavController,
|
onNavigateToCustomRecordingNotifications: () -> Unit,
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
) {
|
) {
|
||||||
val dataStore = LocalContext.current.dataStore
|
val dataStore = LocalContext.current.dataStore
|
||||||
@ -35,7 +35,8 @@ fun CustomNotificationTile(
|
|||||||
SettingsTile(
|
SettingsTile(
|
||||||
firstModifier = Modifier
|
firstModifier = Modifier
|
||||||
.clickable {
|
.clickable {
|
||||||
navController.navigate(Screen.CustomRecordingNotifications.route)
|
|
||||||
|
onNavigateToCustomRecordingNotifications()
|
||||||
}
|
}
|
||||||
.semantics { contentDescription = label },
|
.semantics { contentDescription = label },
|
||||||
title = stringResource(R.string.ui_settings_option_customNotification_title),
|
title = stringResource(R.string.ui_settings_option_customNotification_title),
|
@ -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.Icons
|
||||||
import androidx.compose.material.icons.filled.DeleteSweep
|
import androidx.compose.material.icons.filled.DeleteSweep
|
||||||
@ -34,13 +34,11 @@ fun DeleteRecordingsImmediatelyTile(
|
|||||||
},
|
},
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = settings.audioRecorderSettings.deleteRecordingsImmediately,
|
checked = settings.deleteRecordingsImmediately,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.setAudioRecorderSettings(
|
it.setDeleteRecordingsImmediately(it.deleteRecordingsImmediately.not())
|
||||||
it.audioRecorderSettings.setDeleteRecordingsImmediately(it.audioRecorderSettings.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@ -6,18 +6,18 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.icons.filled.Upload
|
import androidx.compose.material.icons.filled.Upload
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.SnackbarVisuals
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -98,8 +98,8 @@ fun ImportExport(
|
|||||||
duration = SnackbarDuration.Short,
|
duration = SnackbarDuration.Short,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CheckCircle,
|
Icons.Default.CheckCircle,
|
||||||
@ -111,11 +111,10 @@ fun ImportExport(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
Button(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
settingsToBeImported = null
|
settingsToBeImported = null
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.textButtonColors(),
|
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.dialog_close_cancel_label))
|
Text(stringResource(R.string.dialog_close_cancel_label))
|
||||||
}
|
}
|
||||||
@ -128,11 +127,11 @@ fun ImportExport(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Button(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
openFile("application/json")
|
openFile("application/json")
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(),
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Download,
|
Icons.Default.Download,
|
||||||
@ -142,7 +141,7 @@ fun ImportExport(
|
|||||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
Text(stringResource(R.string.ui_settings_option_import_label))
|
Text(stringResource(R.string.ui_settings_option_import_label))
|
||||||
}
|
}
|
||||||
Button(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val rawContent = settings.exportToString()
|
val rawContent = settings.exportToString()
|
||||||
|
|
||||||
@ -151,7 +150,7 @@ fun ImportExport(
|
|||||||
|
|
||||||
saveFile(tempFile, "alibi_settings.json")
|
saveFile(tempFile, "alibi_settings.json")
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(),
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Upload,
|
Icons.Default.Upload,
|
@ -1,8 +1,7 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Mic
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.Timer
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -43,10 +41,14 @@ fun IntervalDurationTile(
|
|||||||
|
|
||||||
fun updateValue(intervalDuration: Long) {
|
fun updateValue(intervalDuration: Long) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (intervalDuration > settings.maxDuration) {
|
||||||
|
dataStore.updateData {
|
||||||
|
it.setMaxDuration(intervalDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.setAudioRecorderSettings(
|
it.setIntervalDuration(intervalDuration)
|
||||||
it.audioRecorderSettings.setIntervalDuration(intervalDuration)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -65,7 +67,7 @@ fun IntervalDurationTile(
|
|||||||
},
|
},
|
||||||
config = DurationConfig(
|
config = DurationConfig(
|
||||||
timeFormat = DurationFormat.MM_SS,
|
timeFormat = DurationFormat.MM_SS,
|
||||||
currentTime = settings.audioRecorderSettings.intervalDuration / 1000,
|
currentTime = settings.intervalDuration / 1000,
|
||||||
minTime = 10,
|
minTime = 10,
|
||||||
maxTime = 60 * 60,
|
maxTime = 60 * 60,
|
||||||
)
|
)
|
||||||
@ -88,7 +90,7 @@ fun IntervalDurationTile(
|
|||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(settings.audioRecorderSettings.intervalDuration),
|
text = formatDuration(settings.intervalDuration),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -96,7 +98,7 @@ fun IntervalDurationTile(
|
|||||||
ExampleListRoulette(
|
ExampleListRoulette(
|
||||||
items = AudioRecorderSettings.EXAMPLE_DURATION_TIMES,
|
items = AudioRecorderSettings.EXAMPLE_DURATION_TIMES,
|
||||||
onItemSelected = ::updateValue,
|
onItemSelected = ::updateValue,
|
||||||
) {duration ->
|
) { duration ->
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(duration),
|
text = formatDuration(duration),
|
||||||
)
|
)
|
@ -1,7 +1,6 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Memory
|
|
||||||
import androidx.compose.material.icons.filled.Timer
|
import androidx.compose.material.icons.filled.Timer
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@ -42,10 +40,14 @@ fun MaxDurationTile(
|
|||||||
|
|
||||||
fun updateValue(maxDuration: Long) {
|
fun updateValue(maxDuration: Long) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
if (maxDuration < settings.intervalDuration) {
|
||||||
|
dataStore.updateData {
|
||||||
|
it.setIntervalDuration(maxDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dataStore.updateData {
|
dataStore.updateData {
|
||||||
it.setAudioRecorderSettings(
|
it.setMaxDuration(maxDuration)
|
||||||
it.audioRecorderSettings.setMaxDuration(maxDuration)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,9 +66,9 @@ fun MaxDurationTile(
|
|||||||
},
|
},
|
||||||
config = DurationConfig(
|
config = DurationConfig(
|
||||||
timeFormat = DurationFormat.HH_MM,
|
timeFormat = DurationFormat.HH_MM,
|
||||||
currentTime = settings.audioRecorderSettings.maxDuration / 1000,
|
currentTime = settings.maxDuration / 1000,
|
||||||
minTime = 60,
|
minTime = 60,
|
||||||
maxTime = 10 * 24 * 60 * 60,
|
maxTime = 23 * 60 * 60 + 59 * 60,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
@ -86,7 +88,7 @@ fun MaxDurationTile(
|
|||||||
),
|
),
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
) {
|
) {
|
||||||
Text(formatDuration(settings.audioRecorderSettings.maxDuration))
|
Text(formatDuration(settings.maxDuration))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
extra = {
|
extra = {
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,54 +0,0 @@
|
|||||||
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.GraphicEq
|
|
||||||
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 com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ForceExactMaxDurationTile(
|
|
||||||
settings: AppSettings,
|
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val dataStore = LocalContext.current.dataStore
|
|
||||||
|
|
||||||
fun updateValue(forceExactMaxDuration: Boolean) {
|
|
||||||
scope.launch {
|
|
||||||
dataStore.updateData {
|
|
||||||
it.setAudioRecorderSettings(
|
|
||||||
it.audioRecorderSettings.setForceExactMaxDuration(forceExactMaxDuration)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
SettingsTile(
|
|
||||||
title = stringResource(R.string.ui_settings_option_forceExactDuration_title),
|
|
||||||
description = stringResource(R.string.ui_settings_option_forceExactDuration_description),
|
|
||||||
leading = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.GraphicEq,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Switch(
|
|
||||||
checked = settings.audioRecorderSettings.forceExactMaxDuration,
|
|
||||||
onCheckedChange = ::updateValue,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
@ -28,7 +28,6 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.navOptions
|
|
||||||
import app.myzel394.alibi.dataStore
|
import app.myzel394.alibi.dataStore
|
||||||
import app.myzel394.alibi.db.AppSettings
|
import app.myzel394.alibi.db.AppSettings
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -0,0 +1,196 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Timer
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.contentColorFor
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.dataStore
|
||||||
|
import app.myzel394.alibi.ui.utils.IconResource
|
||||||
|
import com.maxkeppeker.sheets.core.models.base.Header
|
||||||
|
import com.maxkeppeker.sheets.core.models.base.IconSource
|
||||||
|
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
|
||||||
|
import com.maxkeppeler.sheets.duration.DurationDialog
|
||||||
|
import com.maxkeppeler.sheets.duration.models.DurationConfig
|
||||||
|
import com.maxkeppeler.sheets.duration.models.DurationFormat
|
||||||
|
import com.maxkeppeler.sheets.duration.models.DurationSelection
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
const val MINUTES_1 = 1000 * 60 * 1L
|
||||||
|
const val MINUTES_5 = 1000 * 60 * 5L
|
||||||
|
const val MINUTES_15 = 1000 * 60 * 15L
|
||||||
|
const val MINUTES_30 = 1000 * 60 * 30L
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MaxDurationSelector(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val OPTIONS = mapOf<Long, String>(
|
||||||
|
MINUTES_1 to stringResource(R.string.ui_welcome_timeSettings_values_1min),
|
||||||
|
MINUTES_5 to stringResource(R.string.ui_welcome_timeSettings_values_5min),
|
||||||
|
MINUTES_15 to stringResource(R.string.ui_welcome_timeSettings_values_15min),
|
||||||
|
MINUTES_30 to stringResource(R.string.ui_welcome_timeSettings_values_30min),
|
||||||
|
)
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val dataStore = LocalContext.current.dataStore
|
||||||
|
|
||||||
|
var selectedDuration by rememberSaveable { mutableLongStateOf(MINUTES_15) };
|
||||||
|
|
||||||
|
// Make sure appSettings is updated properly
|
||||||
|
LaunchedEffect(selectedDuration) {
|
||||||
|
scope.launch {
|
||||||
|
dataStore.updateData {
|
||||||
|
it.setMaxDuration(selectedDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.then(modifier),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
for ((duration, label) in OPTIONS) {
|
||||||
|
val a11yLabel = stringResource(
|
||||||
|
R.string.a11y_selectValue,
|
||||||
|
label
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = a11yLabel
|
||||||
|
}
|
||||||
|
.clickable {
|
||||||
|
selectedDuration = duration
|
||||||
|
}
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedDuration == duration,
|
||||||
|
onClick = { selectedDuration = duration },
|
||||||
|
)
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
val showDialog = rememberUseCaseState()
|
||||||
|
val label = stringResource(R.string.ui_welcome_timeSettings_values_custom)
|
||||||
|
val selected = selectedDuration !in OPTIONS.keys
|
||||||
|
|
||||||
|
DurationDialog(
|
||||||
|
state = showDialog,
|
||||||
|
header = Header.Default(
|
||||||
|
title = stringResource(R.string.ui_settings_option_maxDuration_title),
|
||||||
|
icon = IconSource(
|
||||||
|
painter = IconResource.fromImageVector(Icons.Default.Timer)
|
||||||
|
.asPainterResource(),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
selection = DurationSelection { newTimeInSeconds ->
|
||||||
|
selectedDuration = newTimeInSeconds * 1000L
|
||||||
|
},
|
||||||
|
config = DurationConfig(
|
||||||
|
timeFormat = DurationFormat.HH_MM,
|
||||||
|
currentTime = selectedDuration / 1000,
|
||||||
|
minTime = 60,
|
||||||
|
maxTime = 23 * 60 * 60 + 60 * 59,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics {
|
||||||
|
contentDescription = label
|
||||||
|
}
|
||||||
|
.clickable {
|
||||||
|
showDialog.show()
|
||||||
|
}
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Edit,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.minimumInteractiveComponentSize()
|
||||||
|
.padding(2.dp),
|
||||||
|
tint = if (selected) MaterialTheme.colorScheme.primary else contentColorFor(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (selected) {
|
||||||
|
val totalMinutes = selectedDuration / 1000 / 60
|
||||||
|
val minutes = totalMinutes % 60
|
||||||
|
val hours = (totalMinutes / 60).toInt()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when (hours) {
|
||||||
|
0 -> stringResource(
|
||||||
|
R.string.ui_welcome_timeSettings_values_customFormat_mm,
|
||||||
|
minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
1 -> stringResource(
|
||||||
|
R.string.ui_welcome_timeSettings_values_customFormat_h_mm,
|
||||||
|
minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> stringResource(
|
||||||
|
R.string.ui_welcome_timeSettings_values_customFormat_hh_mm,
|
||||||
|
hours,
|
||||||
|
minutes
|
||||||
|
)
|
||||||
|
},
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.ui_welcome_timeSettings_values_custom),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,206 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.PermMedia
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||||
|
import app.myzel394.alibi.ui.SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.MessageBox
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.MessageType
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.VisualDensity
|
||||||
|
|
||||||
|
const val CUSTOM_FOLDER = "custom"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SaveFolderSelection(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
saveFolder: String?,
|
||||||
|
isLowOnStorage: Boolean,
|
||||||
|
onSaveFolderChange: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
@Composable
|
||||||
|
fun createModifier(a11yLabel: String, onClick: () -> Unit) =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.semantics {
|
||||||
|
contentDescription = a11yLabel
|
||||||
|
}
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(16.dp)
|
||||||
|
.padding(end = 8.dp)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.medium)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.then(modifier),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
let {
|
||||||
|
val label = stringResource(R.string.ui_welcome_saveFolder_values_internal)
|
||||||
|
val a11yLabel = stringResource(
|
||||||
|
R.string.a11y_selectValue,
|
||||||
|
label
|
||||||
|
)
|
||||||
|
val folder = null
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = createModifier(a11yLabel) {
|
||||||
|
onSaveFolderChange(folder)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = saveFolder == folder,
|
||||||
|
onClick = { onSaveFolderChange(folder) },
|
||||||
|
)
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(ButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
val label = stringResource(R.string.ui_welcome_saveFolder_values_media)
|
||||||
|
val a11yLabel = stringResource(
|
||||||
|
R.string.a11y_selectValue,
|
||||||
|
label
|
||||||
|
)
|
||||||
|
val folder = RECORDER_MEDIA_SELECTED_VALUE
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = createModifier(a11yLabel) {
|
||||||
|
onSaveFolderChange(folder)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = saveFolder == folder,
|
||||||
|
onClick = { onSaveFolderChange(folder) },
|
||||||
|
)
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PermMedia,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(ButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
val label = stringResource(R.string.ui_welcome_saveFolder_values_custom)
|
||||||
|
val a11yLabel = stringResource(
|
||||||
|
R.string.a11y_selectValue,
|
||||||
|
label
|
||||||
|
)
|
||||||
|
val folder = CUSTOM_FOLDER
|
||||||
|
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = createModifier(a11yLabel) {
|
||||||
|
onSaveFolderChange(folder)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = saveFolder == folder,
|
||||||
|
onClick = { onSaveFolderChange(folder) },
|
||||||
|
)
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Folder,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(ButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 32.dp, vertical = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_settings_option_saveFolder_videoUnsupported),
|
||||||
|
fontSize = MaterialTheme.typography.titleSmall.fontSize,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_minApiRequired, 8, 26),
|
||||||
|
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isLowOnStorage && saveFolder == null)
|
||||||
|
MessageBox(
|
||||||
|
type = MessageType.ERROR,
|
||||||
|
message = stringResource(R.string.ui_welcome_saveFolder_externalRequired)
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.widthIn(max = 400.dp)
|
||||||
|
) {
|
||||||
|
MessageBox(
|
||||||
|
type = MessageType.INFO,
|
||||||
|
message = stringResource(R.string.ui_welcome_timeSettings_changeableHint),
|
||||||
|
density = VisualDensity.DENSE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -64,6 +64,7 @@ fun ExplanationPage(
|
|||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ChevronRight,
|
Icons.Default.ChevronRight,
|
@ -0,0 +1,111 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
||||||
|
|
||||||
|
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.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AccessTime
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
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.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||||
|
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.MaxDurationSelector
|
||||||
|
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 MaxDurationSettingsPage(
|
||||||
|
onContinue: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.AccessTime,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier.size(128.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_welcome_timeSettings_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_welcome_timeSettings_message),
|
||||||
|
fontStyle = MaterialTheme.typography.bodySmall.fontStyle,
|
||||||
|
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||||
|
color = MaterialTheme.typography.bodySmall.color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 400.dp)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
MaxDurationSelector()
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 400.dp)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
MessageBox(
|
||||||
|
type = MessageType.INFO,
|
||||||
|
message = stringResource(R.string.ui_welcome_timeSettings_changeableHint),
|
||||||
|
density = VisualDensity.DENSE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onContinue() },
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.continue_label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,78 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
||||||
|
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Celebration
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
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.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.myzel394.alibi.R
|
||||||
|
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReadyPage(
|
||||||
|
onContinue: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Celebration,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier.size(128.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_welcome_ready_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_welcome_ready_message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Button(
|
||||||
|
onClick = onContinue,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.ui_welcome_ready_start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
|
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@ -59,19 +59,20 @@ fun ResponsibilityPage(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Button(
|
Button(
|
||||||
onClick = { onContinue() },
|
onClick = onContinue,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(BIG_PRIMARY_BUTTON_SIZE),
|
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Check,
|
Icons.Default.ChevronRight,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(ButtonDefaults.IconSize)
|
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
Text(stringResource(R.string.ui_welcome_start_label))
|
Text(stringResource(R.string.continue_label))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,323 @@
|
|||||||
|
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
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.layout.widthIn
|
||||||
|
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.ChevronLeft
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
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.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.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.BIG_PRIMARY_BUTTON_SIZE
|
||||||
|
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
|
||||||
|
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
||||||
|
import app.myzel394.alibi.ui.components.WelcomeScreen.atoms.SaveFolderSelection
|
||||||
|
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
|
||||||
|
import app.myzel394.alibi.ui.utils.rememberFolderSelectorDialog
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SaveFolderPage(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onContinue: (saveFolder: String?) -> Unit,
|
||||||
|
appSettings: AppSettings,
|
||||||
|
) {
|
||||||
|
var saveFolder by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var isLowOnStorage by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
// Fetching this synchronously results in the UI being blocked.
|
||||||
|
// Instead, we fetch this in a different thread and update the state when we have the result.
|
||||||
|
LaunchedEffect(appSettings, context) {
|
||||||
|
thread {
|
||||||
|
val availableBytes = VideoBatchesFolder.viaInternalFolder(context).getAvailableBytes()
|
||||||
|
|
||||||
|
if (availableBytes == null) {
|
||||||
|
isLowOnStorage = false
|
||||||
|
return@thread
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytesPerMinute = BatchesFolder.requiredBytesForOneMinuteOfRecording(appSettings)
|
||||||
|
val requiredBytes = appSettings.maxDuration / 1000 / 60 * bytesPerMinute
|
||||||
|
|
||||||
|
// Allow for a 10% margin of error
|
||||||
|
isLowOnStorage = availableBytes < requiredBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isLowOnStorage, appSettings.maxDuration) {
|
||||||
|
if (isLowOnStorage) {
|
||||||
|
if (saveFolder == null) {
|
||||||
|
saveFolder = RECORDER_MEDIA_SELECTED_VALUE
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
saveFolder = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.SpaceBetween,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier.size(128.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_welcome_saveFolder_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.ui_welcome_saveFolder_message),
|
||||||
|
fontStyle = MaterialTheme.typography.bodySmall.fontStyle,
|
||||||
|
fontSize = MaterialTheme.typography.bodySmall.fontSize,
|
||||||
|
color = MaterialTheme.typography.bodySmall.color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 400.dp)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
SaveFolderSelection(
|
||||||
|
saveFolder = saveFolder,
|
||||||
|
isLowOnStorage = isLowOnStorage,
|
||||||
|
onSaveFolderChange = { saveFolder = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(40.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(BIG_PRIMARY_BUTTON_SIZE),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ChevronLeft,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var showError by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showError) {
|
||||||
|
_FolderInaccessibleDialog(
|
||||||
|
onClose = {
|
||||||
|
showError = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PermissionRequester(
|
||||||
|
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||||
|
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
|
||||||
|
onPermissionAvailable = { onContinue(saveFolder) },
|
||||||
|
) { requestWritePermission ->
|
||||||
|
val selectFolder = rememberFolderSelectorDialog { folder ->
|
||||||
|
if (folder == null) {
|
||||||
|
return@rememberFolderSelectorDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
folder,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
)
|
||||||
|
|
||||||
|
if (BatchesFolder.canAccessFolder(context, folder)) {
|
||||||
|
onContinue(folder.toString())
|
||||||
|
} else {
|
||||||
|
showError = true
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.releasePersistableUriPermission(
|
||||||
|
folder,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var showCustomFolderHint by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showCustomFolderHint) {
|
||||||
|
_CustomFolderDialog(
|
||||||
|
onAbort = { showCustomFolderHint = false },
|
||||||
|
onOk = {
|
||||||
|
showCustomFolderHint = false
|
||||||
|
selectFolder()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
when (saveFolder) {
|
||||||
|
null -> onContinue(saveFolder)
|
||||||
|
RECORDER_MEDIA_SELECTED_VALUE -> {
|
||||||
|
if (SUPPORTS_SCOPED_STORAGE) {
|
||||||
|
onContinue(saveFolder)
|
||||||
|
} else {
|
||||||
|
requestWritePermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
showCustomFolderHint = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = if (saveFolder == null) !isLowOnStorage else true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(BIG_PRIMARY_BUTTON_SIZE),
|
||||||
|
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(ButtonDefaults.IconSize)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.continue_label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun _FolderInaccessibleDialog(
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onClose,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Error,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.ui_error_occurred_title))
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = onClose) {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun _CustomFolderDialog(
|
||||||
|
onAbort: () -> Unit,
|
||||||
|
onOk: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onAbort,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Folder,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.ui_welcome_saveFolder_customFolder_title))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.ui_welcome_saveFolder_customFolder_message))
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = onAbort,
|
||||||
|
contentPadding = ButtonDefaults.TextButtonContentPadding,
|
||||||
|
colors = ButtonDefaults.textButtonColors(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.dialog_close_cancel_label))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = onOk,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.dialog_close_neutral_label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
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