Compare commits

...

735 Commits

Author SHA1 Message Date
Myzel394
c7f46514a7
fix(docs): Fix wording
Closes #134
2025-01-06 14:51:06 +01:00
Myzel394
5335dc934c
Merge pull request #129 from pixel2user/patch-1
Update README.md
2024-10-20 08:10:23 +02:00
pixel2user
4526eac31b
Update README.md
The important keywords audio/video are missing in the readme and are only written onto one of the screenshots.
2024-10-19 22:55:11 +02:00
Myzel394
193ecd15d2
Merge pull request #121 from Myzel394/fix-name
Fix empty file
2024-09-07 22:18:46 +02:00
Myzel394
c8de60085a
fix: Fix folder names
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-09-07 22:11:25 +02:00
Myzel394
deff57a54f
chore: Update to new APIs
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-09-07 21:55:53 +02:00
Myzel394
a085a3564b
chore: Update version
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-09-07 21:44:32 +02:00
Myzel394
b4dfe91125
chore: Update dependencies
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-09-07 21:44:26 +02:00
Myzel394
d915f41b33
Merge pull request #118 from Myzel394/l10n_weblate
New Crowdin updates
2024-09-07 21:40:43 +02:00
Myzel394
3cfbbdef1c
fix: Use one source of truth for the filename (instead of recalculating it, this doesn't work, as the date _time_ will differ)
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-09-07 21:39:45 +02:00
Myzel394
fcb470b050 New translations strings.xml (Chinese Simplified) 2024-09-07 10:54:43 +02:00
Myzel394
529d61c3f1 New translations short_description.txt (French) 2024-09-02 02:03:24 +02:00
Myzel394
1a9b1d159c New translations full_description.txt (French) 2024-09-02 02:03:23 +02:00
Myzel394
f0b7c6e450 New translations strings.xml (French) 2024-09-02 02:03:22 +02:00
Myzel394
2db717a1ca New translations strings.xml (French) 2024-09-01 01:45:50 +02:00
Myzel394
366510804f New translations short_description.txt (Italian) 2024-08-24 16:13:50 +02:00
Myzel394
fe1c46f7df New translations full_description.txt (Italian) 2024-08-24 16:13:49 +02:00
Myzel394
4b4ce932bd New translations strings.xml (Italian) 2024-08-24 16:13:48 +02:00
Myzel394
949f55e4b7 New translations strings.xml (Italian) 2024-08-23 15:49:49 +02:00
Myzel394
af09fe7319 New translations strings.xml (Portuguese, Brazilian) 2024-08-21 22:51:35 +02:00
Myzel394
d54f9f560e New translations strings.xml (Vietnamese) 2024-08-21 22:51:34 +02:00
Myzel394
d1070da473 New translations strings.xml (English) 2024-08-21 22:51:33 +02:00
Myzel394
5117247e00 New translations strings.xml (Chinese Traditional) 2024-08-21 22:51:32 +02:00
Myzel394
c41452503b New translations strings.xml (Ukrainian) 2024-08-21 22:51:31 +02:00
Myzel394
213c6977f8 New translations strings.xml (Turkish) 2024-08-21 22:51:30 +02:00
Myzel394
aa5b9a25ae New translations strings.xml (Swedish) 2024-08-21 22:51:30 +02:00
Myzel394
b47b2845b5 New translations strings.xml (Serbian (Cyrillic)) 2024-08-21 22:51:28 +02:00
Myzel394
ebe44dd048 New translations strings.xml (Russian) 2024-08-21 22:51:28 +02:00
Myzel394
d3a9cb0be0 New translations strings.xml (Portuguese) 2024-08-21 22:51:26 +02:00
Myzel394
773f498aad New translations strings.xml (Polish) 2024-08-21 22:51:25 +02:00
Myzel394
095415a78d New translations strings.xml (Norwegian) 2024-08-21 22:51:24 +02:00
Myzel394
8866483e17 New translations strings.xml (Dutch) 2024-08-21 22:51:23 +02:00
Myzel394
f1ddcd5297 New translations strings.xml (Korean) 2024-08-21 22:51:22 +02:00
Myzel394
8d6f2dad13 New translations strings.xml (Japanese) 2024-08-21 22:51:21 +02:00
Myzel394
abd616e94a New translations strings.xml (Italian) 2024-08-21 22:51:20 +02:00
Myzel394
24876387d1 New translations strings.xml (Hungarian) 2024-08-21 22:51:19 +02:00
Myzel394
b2ae731e96 New translations strings.xml (Hebrew) 2024-08-21 22:51:18 +02:00
Myzel394
6a2cbd79e2 New translations strings.xml (Finnish) 2024-08-21 22:51:17 +02:00
Myzel394
e7095cde07 New translations strings.xml (Greek) 2024-08-21 22:51:16 +02:00
Myzel394
6c3ac5d4f2 New translations strings.xml (German) 2024-08-21 22:51:15 +02:00
Myzel394
2037c69f42 New translations strings.xml (Danish) 2024-08-21 22:51:14 +02:00
Myzel394
1c91576afb New translations strings.xml (Czech) 2024-08-21 22:51:13 +02:00
Myzel394
0872a1a2d9 New translations strings.xml (Catalan) 2024-08-21 22:51:12 +02:00
Myzel394
4c94b4ffa0 New translations strings.xml (Arabic) 2024-08-21 22:51:11 +02:00
Myzel394
48e66cbd7e New translations strings.xml (Afrikaans) 2024-08-21 22:51:10 +02:00
Myzel394
3486cac7b2 New translations strings.xml (Spanish) 2024-08-21 22:51:09 +02:00
Myzel394
c79f310fbe New translations strings.xml (French) 2024-08-21 22:51:08 +02:00
Myzel394
73855d01ee New translations strings.xml (Romanian) 2024-08-21 22:51:07 +02:00
Myzel394
1bac86a88c New translations strings.xml (Chinese Simplified) 2024-08-21 22:51:06 +02:00
Myzel394
a88507a905
Merge pull request #108 from Myzel394/fix-105-custom-filename
Add custom filename option
2024-08-21 09:26:43 +02:00
Myzel394
8240a1c458
chore: Update dependencies
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-08-20 22:38:27 +02:00
Myzel394
da8dc613b6
Merge branch 'master' into fix-105-custom-filename 2024-08-20 22:36:39 +02:00
Myzel394
0089d9892d
chore(ci-cd): Reduce userFraction
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-08-20 22:33:55 +02:00
Myzel394
28f36a435c
chore: Update version 2024-08-20 22:33:44 +02:00
Myzel394
0133290ef1
fix: Fix FilenameFormatTile width
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-08-20 22:33:03 +02:00
Myzel394
9490348c5c
fix: Fix FilenameFormatTile width
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-08-20 22:28:07 +02:00
Myzel394
7f644c4dc6
fix: Fix save folder check
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-08-20 22:25:02 +02:00
Myzel394
061ed8b156
fix(ci-cd): Add required shell attribute
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-07-25 21:29:38 +02:00
Myzel394
2501ac32a6
Merge pull request #111 from Myzel394/fix-size-grow
Fix size growing indefinitely
2024-07-25 21:24:44 +02:00
Myzel394
f6de5a4449
chore: Update version
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-07-25 20:52:44 +02:00
Myzel394
73e99afadc
fix: Properly surround filename in quotation marks
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-07-25 20:50:21 +02:00
Myzel394
8cd2fa3b76
chore: Update dependencies
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-07-25 19:58:01 +02:00
Myzel394
ffd1405a61
feat: Add now as filename format
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-06-09 18:00:27 +02:00
Myzel394
ac0fd3fed2
feat: Add FilenameFormatTile to SettingsScreen
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-06-09 17:05:43 +02:00
Myzel394
8fcd5ca487
feat: Add custom date file format support
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-06-09 16:48:35 +02:00
Myzel394
ee4529a9dc
feat: Add filename format to AppSettings
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-06-09 16:30:11 +02:00
Myzel394
048e285f9c
chore: Update dependencies
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-06-09 16:24:57 +02:00
Myzel394
7d949fc16c
fix(ci-cd): Improve CI:CD
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-05-03 19:36:38 +02:00
Myzel394
42f06c67bb
fix(ci-cd): Remove shell attribute 2024-05-02 23:56:14 +02:00
Myzel394
5d0e84e4ab
Merge pull request #103 from Myzel394/fix-100
Improve folder access test
2024-04-20 22:22:56 +02:00
Myzel394
f77aeb78c6
fix: Fix folder access test
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-04-08 20:04:42 +02:00
Myzel394
b076b8c8ab
feat: Test if custom folder is accessible before setting it
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-04-08 20:00:35 +02:00
Myzel394
721a3daeb6
fix: Properly handle errors when starting recording fails
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-04-08 19:35:38 +02:00
Myzel394
df9443eb9b
Merge pull request #98 from Myzel394/feat/0.5.0
Version 0.5.0
2024-04-03 20:47:24 +02:00
Myzel394
6aff6c8683
feat: Make audio bar's sensitivity changeable by pinching (closes #99)
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-04-03 20:41:30 +02:00
Myzel394
08084d207e
fix: Improve responsive design
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-04-03 20:24:57 +02:00
Myzel394
0ec149ee02
fix: small ui improvements
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-04-03 20:14:12 +02:00
Myzel394
6b19cb81ed
fix: Undo unwanted changes
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-04-02 18:56:29 +02:00
Myzel394
c226434204
feat: Fetch isLowOnStorage on different thread to avoid UI being blocked
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-04-02 18:54:42 +02:00
Myzel394
8c9143f1ec
fix: Manually check settings changes
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-04-01 23:49:38 +02:00
Myzel394
f839416ce1
chore: Update ci:cd dependencies
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-30 23:21:10 +01:00
Myzel394
3734008326
chore: Update actions in ci:cd
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-30 23:16:23 +01:00
Myzel394
7166ba1398
chore: Update ci:cd
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-30 23:06:05 +01:00
Myzel394
d1fe46804e
fix: Add workaround for doubly registered events
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-30 23:00:27 +01:00
Myzel394
798b6f2119
fix: Add workaround for DisposableEffect
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-30 22:47:09 +01:00
Myzel394
14d6b36162
chore: Update dependencies
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-29 14:39:42 +01:00
Myzel394
fa88f59170
chore: Add logging; fixes
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-29 14:39:34 +01:00
Myzel394
03e80861e9
chore: Update version
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-23 14:06:59 +01:00
Myzel394
39458bd76c
fix: Improve design
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-23 14:06:51 +01:00
Myzel394
3db93cc96a
chore: Do not shore CameraPreview for now
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-23 14:02:07 +01:00
Myzel394
cfea5a9143
feat: Only show processing dialog after some time
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-23 14:01:53 +01:00
Myzel394
7d83bca1fe
fix: Properly concatenate in own thread wait for end properly
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-23 13:44:53 +01:00
Myzel394
24928661c5
Merge pull request #81 from materoy/master
Add camera preview
2024-03-23 12:29:33 +01:00
Myzel394
53701067ef
Merge branch 'feat/0.5.0' into master 2024-03-23 12:04:40 +01:00
Myzel394
8061ee4fc6
Merge pull request #97 from Myzel394/improve-landing-page
Improve landing page
2024-03-23 12:02:45 +01:00
Myzel394
a122cbea03
fix: Improve BigButton responsive design
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 22:24:47 +01:00
Myzel394
e0dce7d359
feat: Do not show a warning for custom folders anymore
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 22:24:30 +01:00
Myzel394
f8c1db495d
fix: Fix available bytes calculation when using a custom folder
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 22:24:00 +01:00
Myzel394
1b006ba45c
fix: Improve error handling
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 22:06:25 +01:00
Myzel394
bc1dbf01db
fix: Overall improvements
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 21:40:20 +01:00
Myzel394
29984c59a2
fix: Improve design
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 21:11:12 +01:00
Myzel394
a250ec1788
fix: Improve design for little screens
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 21:09:16 +01:00
Myzel394
4e93ff4bb2
feat: Add contact methods
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 21:08:45 +01:00
Myzel394
eb6baac503
fix: Improve styling
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 20:09:09 +01:00
Myzel394
0461fe9596
fix: Use proper media folder for low storage info
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 20:03:01 +01:00
Myzel394
e4e23abcea
fix: Save saveFolder on change
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 19:46:18 +01:00
Myzel394
04c6cd92a3
fix: Improvements
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 19:40:47 +01:00
Myzel394
ac6cc7a5c0
feat: Add ReadyPage
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 19:40:36 +01:00
Myzel394
f9d20c67d7
feat: Automatically select media folder if storage is not enough
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 19:31:31 +01:00
Myzel394
aaee0c5cb6
feat: Add custom value to TimeSelector
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 19:05:13 +01:00
Myzel394
dac133e6b3
fix: Improve WelcomeScreen overflow
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 08:59:58 +01:00
Myzel394
97acb6d977
feat: Improve SaveFolder; Request permission: Show warning if custom folder not supported
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-22 00:10:42 +01:00
Myzel394
f06a79c1a8
feat: Adding SaveFolderSelection
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-21 23:23:12 +01:00
Myzel394
e968e7e589
feat: Add SaveFolderPage
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-21 22:58:35 +01:00
Myzel394
552acdacf0
feat: Add VisualDensity.DENSE to MessageBox
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-21 22:37:02 +01:00
Myzel394
6687b173a5
feat: Add TimeSettingsPage
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-21 22:36:48 +01:00
Myzel394
5cdbb605f2
fix: Show low storage message dependent on whether user uses internal or external storage
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-21 21:54:21 +01:00
Myzel394
a6856476ce
feat: Add low on storage info; Closes #56
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-21 20:11:59 +01:00
Myzel394
0ebbf86450
fix: Improve SaveButton width 2024-03-21 19:31:31 +01:00
Myzel394
ea1a7701bd
fix: Improve responsive layout; Closes #65
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-21 19:19:31 +01:00
Myzel394
bc609ae34b
fix: Improve wording; Closes #85
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-21 19:11:25 +01:00
Myzel394
9c5cba2c91
chore: Update version
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-21 19:08:08 +01:00
Myzel394
e7961c436b
Merge remote-tracking branch 'origin/master' 2024-03-19 22:31:03 +01:00
Myzel394
f895700e34
fix: Only backup datastore; Closes #51
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-19 22:30:54 +01:00
Myzel394
7620754bd4
Merge pull request #95 from Myzel394/fix-save-after-pause-90
Fix save after pause not working reliably
2024-03-18 17:29:52 +01:00
Myzel394
24383a7bd8
Merge pull request #94 from Myzel394/add-auto-save-83
Add save current status functionality
2024-03-18 17:02:53 +01:00
Myzel394
41f1aca833
fix: Always listen for video finalization event
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-18 17:02:38 +01:00
Myzel394
227e075c0b
feat: Add save current option to VideoRecordingStatus
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-18 16:38:57 +01:00
Myzel394
a39efe8f68
feat: Add confirmation modal for saving current recording
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-17 22:01:54 +01:00
Myzel394
cf72b91f69
feat: Add save current on long press for audio recorder
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-17 21:03:39 +01:00
Myzel394
dad8439d3d
feat: Add save current audio recording without stopping it (wip)
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-17 18:19:38 +01:00
Myzel394
fe24f3473f
feat: Add onLongClick to SaveButton
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-16 19:14:59 +01:00
Myzel394
6627289666
feat: Add lock / unlock method for interval files
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-16 18:43:26 +01:00
Myzel394
671c8da56a
refactor: Move recording save stuff out to audio and video recording statuses component
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-16 17:28:45 +01:00
Myzel394
0a626e5f66
feat: Add completer to recording save functions
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-16 16:57:12 +01:00
Myzel394
bc6a4a4660
Merge pull request #88 from Myzel394/fix-crash-issue-86
Fix duration crash
2024-03-16 16:28:45 +01:00
Myzel394
c25decc777
feat: Automatically change interval duration and max duration
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-03-16 15:26:38 +01:00
Myzel394
b98a8a2103
chore: Update dependencies 2024-03-10 19:52:19 +01:00
Roy Matero
386614800b fix: change camera preview to full screen size 2024-02-26 18:41:21 -08:00
Roy Matero
6f8b68c7b2 feature: adds camera preview 2024-02-24 14:24:49 -08:00
Myzel394
38fc299fc6
Merge pull request #80 from Myzel394/fix-71-improve-landscape
Improve landscape
2024-02-23 23:48:32 +01:00
Myzel394
a73a1efcd9
chore(ui): Update icons to auto mirrored version
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 23:31:30 +01:00
Myzel394
8d070b370e
fix: Add missing i18n strings to CustomRecordingNotificationsScreen
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 23:20:16 +01:00
Myzel394
86382afc1b
feat: Add RotateDeviceToPortrait to CustomRecordingNotificationsScreen
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 23:18:52 +01:00
Myzel394
b0f7d98d1f
fix(ui): Make InternalFolderExplanationDialog scrollable
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 23:07:16 +01:00
Myzel394
797ecce9ca
fix(ui): Remove Spacer on StartRecording on landscape to provider more space for the buttons
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 23:07:00 +01:00
Myzel394
2de62d0d29
Merge branch 'master' into fix-71-improve-landscape 2024-02-23 23:04:03 +01:00
Myzel394
213519daf5
Merge pull request #79 from Myzel394/improve-help
Improve help description
2024-02-23 23:03:30 +01:00
Myzel394
54c1e526ed
fix(ui): Make SaveFolderTile explanation scrollable
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 22:55:38 +01:00
Myzel394
08edde3421
cleanup(docs): Improve services README
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 22:51:42 +01:00
Myzel394
ba50d48f23
chore(docs): Add documentation for ui folder
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 22:51:31 +01:00
Myzel394
f426cfe287
refactor: Use optimistic updates for torch
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 22:46:13 +01:00
Myzel394
590f3f2d13
fix(ui): Make VideoRecordingStatus more responsive for landscape
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 22:41:31 +01:00
Myzel394
e5d594a273
fix(ui): Make RealtimeAudioVisualizer store more amplitudes than current width would allow to allow user to rotate its device
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 22:12:28 +01:00
Myzel394
af19eec613
fix(ui): Make AudioRecordingStatus more responsive for landscape mode
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 22:09:31 +01:00
Myzel394
cc4af773fb
fix(ui): Make StartRecording more responsive for landscape mode
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 21:54:07 +01:00
Myzel394
db58bfc408
fix: Improve wording
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 21:32:08 +01:00
Myzel394
1fe99b2aa6
feat: Add explanation to internal storage
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 21:30:44 +01:00
Myzel394
880a81f919
chore: Update kotlin
Signed-off-by: Myzel394 <50424412+Myzel394@users.noreply.github.com>
2024-02-23 21:08:33 +01:00
Myzel394
91f567882c
chore: Migrate deprecated Divider to HorizontalDivider 2024-02-23 20:53:55 +01:00
Myzel394
b0ae92226c
chore: Update dependencies 2024-02-23 20:47:52 +01:00
Myzel394
0ca094426e
Merge pull request #77 from materoy/master
Refactoring to handle all navigation in the navigation.kt file
2024-02-23 20:31:06 +01:00
Roy Matero
011d760c74 refactor: Passed down navigation action lambda instead of nav controller 2024-02-18 08:48:07 -08:00
Roy Matero
41b3801d60 Update .gitignore 2024-02-18 08:48:07 -08:00
Myzel394
7e19a07303
Merge remote-tracking branch 'origin/master' 2024-02-06 17:32:21 +01:00
Myzel394
9862b906d1
chore: Add video usage example 2024-02-06 17:32:16 +01:00
Myzel394
a887b52912
chore(docs): Outsource crypto into its own repop 2024-01-11 08:53:42 +01:00
Myzel394
5964966a8f
chore: Improve CI:CD 2024-01-10 21:44:52 +01:00
Myzel394
52717562b3
chore: Update version code for Google Play 2024-01-10 21:38:39 +01:00
Myzel394
8c84405389
chore: Increase version code for Google Play 2024-01-10 21:26:35 +01:00
Myzel394
2250be19e0
chore: Increase version code for Google Play 2024-01-10 20:02:28 +01:00
Myzel394
06af5e6ace
fix: Fix name 2024-01-10 19:48:44 +01:00
Myzel394
3cc5327435
Merge pull request #55 from Myzel394/add-video
Add video support
2024-01-10 19:39:57 +01:00
Myzel394
dd5344c2a0
fix: Only require FOREGROUND_SERVICE_TYPE_CAMERA when no audio requested 2024-01-10 14:49:44 +01:00
Myzel394
cfec7147fb
refactor: Small improvement 2024-01-09 22:36:39 +01:00
Myzel394
9202c21125
fix: Handle unknown camera lenses 2024-01-09 22:35:18 +01:00
Myzel394
88ba0444e8
chore: Increase version code 2024-01-09 22:13:11 +01:00
Myzel394
e71dabed85
fix(fastlane): Convert image to webp 2024-01-07 18:57:15 +01:00
Myzel394
8a9d7abdd1
chore(docs): Update README.md images 2024-01-07 18:52:03 +01:00
Myzel394
54a608ad71
chore(fastlane): Update images 2024-01-07 18:51:00 +01:00
Myzel394
afd114fd2b
fix: Improve AboutScreen 2024-01-06 17:53:26 +01:00
Myzel394
e8aa6ccc95
fix: Make RealtimeAudioVisualizer size more adaptive 2024-01-06 17:39:24 +01:00
Myzel394
be2a1b9785
fix: Make SaveFolderTile.kt scrollable 2024-01-06 17:35:29 +01:00
Myzel394
c0b1651608
fix: Make recorder start screen more adaptive 2024-01-06 17:35:15 +01:00
Myzel394
20e79b9c83
fix: Only show preview message if camera permission granted 2024-01-06 16:57:16 +01:00
Myzel394
f06264072e
fix: Remove padding so button is visible on smaller devices 2024-01-06 16:56:52 +01:00
Myzel394
e9eb7089a9
fix: Keep screen on when processing 2024-01-06 16:56:37 +01:00
Myzel394
cfc3572cfa
fix(ui): Unify buttons 2024-01-06 16:36:35 +01:00
Myzel394
e6d31a0ba9
fix: Small improvements 2024-01-06 13:54:04 +01:00
Myzel394
300825e20f
feat: Add explanation to SaveFolderTile.kt for DCIM folder 2024-01-05 21:58:29 +01:00
Myzel394
42f8262796
feat: Add explanation to SaveFolderTile.kt for DCIM folder 2024-01-05 21:57:49 +01:00
Myzel394
caeb966598
fix: Fix batches folder concatenation 2024-01-05 21:57:24 +01:00
Myzel394
986bf1d98a
feat: Add open folder for custom folders in SaveFolderTile 2024-01-05 20:17:12 +01:00
Myzel394
60ee8d9395
refactor: Outsource function into rememberOpenUri 2024-01-05 20:16:53 +01:00
Myzel394
3cbf822b88
fix: Small improvements 2024-01-05 19:56:20 +01:00
Myzel394
298ce13369
fix: Fix recorder for Android level 33 2024-01-05 19:39:19 +01:00
Myzel394
08a6d557f8
fix: Fix recording for Android API 30 2024-01-05 19:12:53 +01:00
Myzel394
0372b44901
chore: remove debug logs 2024-01-05 13:45:34 +01:00
Myzel394
c6932fd31d
feat: Add progress bar; Closes #64 2024-01-05 13:43:50 +01:00
Myzel394
e8df9fbc28
fix: Improve save folder 2024-01-05 13:43:23 +01:00
Myzel394
b1fc546f3b
feat: Request external storage permission on recording start if not granted already 2024-01-04 21:39:59 +01:00
Myzel394
fc9e6d7721
feat: Add progress info to audio recorder 2024-01-04 20:59:18 +01:00
Myzel394
386d3cb733
feat: Add progress bar support for video 2024-01-04 00:13:49 +01:00
Myzel394
3f1e00ac82
refactor: Rename translation strings 2024-01-02 22:11:37 +01:00
Myzel394
f35a54cfa6
fix: Fix batches collector 2024-01-02 17:24:04 +01:00
Myzel394
b8787586db
chore: remove empty line 2024-01-01 17:22:34 +01:00
Myzel394
eeaeef07a8
fix: Fix modal bottom sheet 2024-01-01 17:18:34 +01:00
Myzel394
9bc6908bb9
fix: Improve Doctor's checkIfFileSaverDialogIsAvailable 2024-01-01 15:23:49 +01:00
Myzel394
cba150b72e
feat: Hide current snackbar on recording start 2024-01-01 15:10:57 +01:00
Myzel394
7b9457fc58
feat: Add onRecordingStart listener 2024-01-01 15:10:44 +01:00
Myzel394
df820e59fd
fix: Show content faster when no app lock is enabled 2024-01-01 14:55:30 +01:00
Myzel394
29d4e5a86a
feat: Add doctor; Add check for file saver dialog 2024-01-01 14:53:42 +01:00
Myzel394
d24dd4cf4b
fix: Fix function not being referenced anymore 2024-01-01 00:31:55 +01:00
Myzel394
cf6c653ad3
feat: Show error dialog when batches folder is inaccessible 2024-01-01 00:23:51 +01:00
Myzel394
47fc65aaf2
fix: typo; happy new years eve 2024-01-01 00:06:00 +01:00
Myzel394
32d35c5cd7
chore: cleanup 2023-12-31 23:40:54 +01:00
Myzel394
3a542f3a4d
fix: Sort batches 2023-12-31 23:37:32 +01:00
Myzel394
da34df6b87
refactor: Use constant SHEET_BOTTOM_OFFSET everywhere 2023-12-31 22:58:37 +01:00
Myzel394
2c09245697
refactor: Rename onCustomOutputFolderNotAccessible -> onBatchesFolderNotAccessible 2023-12-31 22:55:43 +01:00
Myzel394
666f0bee70
feat: Check if external storage permission is granted on old Android version if media is selected 2023-12-31 22:54:47 +01:00
Myzel394
b581505c97
fix: Improve MessageBox colors 2023-12-31 22:49:16 +01:00
Myzel394
e8337f2fc2
fix: Fix message info 2023-12-31 22:45:27 +01:00
Myzel394
5e3e2a2e22
feat: Add permission required dialog to SaveFolder; some refactoring 2023-12-31 22:40:20 +01:00
Myzel394
f4334bf26b
fix: Fix mainly Audio recording and some bugfixes for video recording 2023-12-31 22:10:14 +01:00
Myzel394
fa72ee096e
feat: Show snackbar on save folder change 2023-12-31 21:15:42 +01:00
Myzel394
de72f88953
feat: Improve SaveFolderTile selected value and order of selectionsheet 2023-12-31 21:11:31 +01:00
Myzel394
d6ab56f027
feat: Add selection sheet for SaveFolderTile 2023-12-31 21:07:34 +01:00
Myzel394
1187d83e86
feat: Fix icons, add more mipmaps 2023-12-31 20:12:12 +01:00
Myzel394
cb25c1bb90
fix: Improve folders 2023-12-31 19:58:50 +01:00
Myzel394
b707681a63
fix: Avoid unnecessary call if earlierCounter is negative 2023-12-31 17:56:41 +01:00
Myzel394
69b76a7640
fix: Delete old files for legacy media storage 2023-12-31 17:56:20 +01:00
Myzel394
7c646835e9
fix: Convert recordingTime to float before calculation to fix progress bar 2023-12-31 16:54:15 +01:00
Myzel394
61a63eeabb
fix: Improve queries 2023-12-31 16:04:02 +01:00
Myzel394
4681a1d924
fix: Fix legacy storage support 2023-12-31 15:49:28 +01:00
Myzel394
ef6487903e
fix: Improvements 2023-12-31 00:16:37 +01:00
Myzel394
9d4345c2d1
refactor: Use constants instead of hardcoded values 2023-12-30 21:07:30 +01:00
Myzel394
7c6e44dd69
refactor: Outsource into getOrCreateNewMediaFile method 2023-12-30 21:04:24 +01:00
Myzel394
329b41b4c8
feat: Improve check for existing file query 2023-12-30 21:00:13 +01:00
Myzel394
c6dca0fc77
refactor: Small code improvements, make code more dry 2023-12-30 20:59:59 +01:00
Myzel394
35614a7b7a
fix: Make constant name easier to read for end users 2023-12-30 20:59:20 +01:00
Myzel394
7401454269
feat: Add support for media folders to video recording 2023-12-30 20:39:33 +01:00
Myzel394
99085b2176
feat: Add media option to SaveFolderTile 2023-12-30 20:36:50 +01:00
Myzel394
b1167577ef
fix: Delete cache file after using it 2023-12-30 19:10:46 +01:00
Myzel394
ad4caafb23
feat(docs): Add link to F-Droid; Closes #61 2023-12-30 11:46:53 +01:00
Myzel394
cb9a86be67
fix: Unify save button with delete button in RecordingControl 2023-12-29 23:00:37 +01:00
Myzel394
8b4c46a931
feat: Add hint for old Android version for video recorder custom folder notice 2023-12-29 22:37:46 +01:00
Myzel394
4950dc3505
refactor: Outsource check into Constants.kt 2023-12-29 21:59:49 +01:00
Myzel394
1128a6771d
feat: Add support for custom folder in VideoRecorderService 2023-12-29 21:54:05 +01:00
Myzel394
4af8cd7318
feat: Add support for custom folder 2023-12-29 21:53:48 +01:00
Myzel394
4126dded6e
fix: Update AudioRecorderService to new batches folder 2023-12-29 21:49:28 +01:00
Myzel394
76b384ffb6
refactor: Small improvement for taking persistable uri permission 2023-12-29 21:49:12 +01:00
Myzel394
5f1b6dcb43
fix: Update RecorderEventsHandler to new base models 2023-12-29 21:48:41 +01:00
Myzel394
ab108305ef
refactor: Improve recorder models 2023-12-29 21:48:19 +01:00
Myzel394
06f2e1de5e
feat: Add custom VideoBatchesFolder support to VideoRecorderModel 2023-12-29 21:48:04 +01:00
Myzel394
029d9fe302
feat: clean up BatchesFolder on stop; 2023-12-29 21:47:46 +01:00
Myzel394
28864ca264
refactor: Improve BatchesFolder 2023-12-29 21:46:41 +01:00
Myzel394
5416fcb046
Merge pull request #60 from Myzel394/add-fingerprint
Add fingerprint (App Lock)
2023-12-25 12:03:59 +01:00
Myzel394
2d22a65506
feat: Improve AsLockedApp 2023-12-25 11:54:32 +01:00
Myzel394
6661d457ea
feat: Add AsLockedApp wrapper to require id verification if enabled 2023-12-23 20:41:36 +01:00
Myzel394
025a8a3209
feat: Add EnableAppLockTile to SettingsScreen 2023-12-23 20:18:46 +01:00
Myzel394
a73fc6c48f
feat: Add biometric authentication check 2023-12-23 19:57:55 +01:00
Myzel394
bf84396a86
current stand 2023-12-21 18:12:46 +01:00
Myzel394
7dfa29856e
feat: Add LockedApp 2023-12-21 12:46:05 +01:00
Myzel394
3cc34faa02
fix: Remove unnecessary runCatching 2023-12-17 21:54:39 +01:00
Myzel394
12311d392b
fix: Properly set isStartingRecording for video recording 2023-12-17 21:54:22 +01:00
Myzel394
e719c0f8fb
feat: Add some optional delay to RecordingControl 2023-12-17 21:27:47 +01:00
Myzel394
76752e9004
fix: Properly save recording 2023-12-17 21:24:12 +01:00
Myzel394
df0bb35672
feat: Don't show RecordingControl until recording ready 2023-12-16 23:44:28 +01:00
Myzel394
4f9f65d0b1
fix: Only start recording if recordingstate is idle 2023-12-16 23:09:10 +01:00
Myzel394
b1c77fe16a
fix: Unify spacing of audio and video status 2023-12-16 22:44:10 +01:00
Myzel394
ee391a914b
fix: Unify spacing of audio and video status 2023-12-16 22:28:32 +01:00
Myzel394
cda0b7f195
fix: Properly reconnect audio on app focus 2023-12-16 22:13:38 +01:00
Myzel394
ff8ea3e1f2
feat: Add video recorder starting info 2023-12-16 21:15:00 +01:00
Myzel394
a0640d13ab
fix: Inline variable 2023-12-16 21:07:41 +01:00
Myzel394
b5ae6a735d
fix: Add missing permissions and optional uses-feature 2023-12-16 21:07:28 +01:00
Myzel394
459a0b18ba
feat: Add permission check 2023-12-16 21:07:12 +01:00
Myzel394
096cf56436
feat: Add more animations to recording start 2023-12-16 12:48:00 +01:00
Myzel394
b6346ef0e3
feat: Add start time info 2023-12-16 11:20:12 +01:00
Myzel394
750f6dc212
fix: Use onSurface color 2023-12-16 10:55:52 +01:00
Myzel394
3aa4caf9ed
fix: Add some spacing 2023-12-15 22:17:15 +01:00
Myzel394
101f8d46e9
fix: Fix sheets coming and leaving 2023-12-15 22:14:48 +01:00
Myzel394
39895bcd40
feat: Add QuickMaxDurationSelector; closes #57 2023-12-15 22:05:30 +01:00
Myzel394
f462d5ff50
feat: Add Alibi icon next to explanation 2023-12-15 21:30:08 +01:00
Myzel394
c2a9791680
feat: Add save last recording functionality again 2023-12-15 21:24:28 +01:00
Myzel394
1791c67518
feat: Start recording with default params on click and show config on long press 2023-12-15 21:08:08 +01:00
Myzel394
ed8ab1d7b0
chore: Update dependencies 2023-12-15 20:40:11 +01:00
Myzel394
5f8abca57a
feat: Add active camera to VideoRecordingStatus 2023-12-15 20:35:10 +01:00
Myzel394
f772818b7b
fix: Destroy service after stopping it when deleting recording 2023-12-15 20:03:14 +01:00
Myzel394
fa5cd6fbca
fix: Differentiate between stopping and destroying -> destroy service after stoppind and saving last recording information 2023-12-15 19:54:42 +01:00
Myzel394
4be2fc52e2
fix: Fix recorder states 2023-12-15 19:40:46 +01:00
Myzel394
3d355df522
current stand 2023-12-15 18:44:47 +01:00
Myzel394
de30f681e8
docs: Add docs for services 2023-12-14 18:04:02 +01:00
Myzel394
d1e4b68e33
fix: Save audio and video using appropriate file saver 2023-12-14 17:29:15 +01:00
Myzel394
df78952cfb
fix: Set state after action to avoid coroutine going out of scope 2023-12-13 15:20:53 +01:00
Myzel394
d79aabab50
fix: Properly cleanup recordings 2023-12-09 10:37:45 +01:00
Myzel394
3cba3382f3
refactor: Improving Recorder events handling 2023-12-06 21:57:04 +01:00
Myzel394
ce50ed1d68
feat(ui): Add more space to SectionTitle.kt 2023-12-06 20:52:14 +01:00
Myzel394
0192af584c
refactor: Improve RecordingStatuses 2023-12-06 20:45:53 +01:00
Myzel394
515c43deb5
feat: Add TorchStatus 2023-12-06 20:20:02 +01:00
Myzel394
536cba8780
refactor: Rename RecordingStatus.kt -> AudioRecordingStatus.kt 2023-12-06 18:30:21 +01:00
Myzel394
453ee79b1a
fix(ui): Move information down 2023-12-06 18:28:28 +01:00
Myzel394
e116c192f6
fix: Call onAmplitudeChange on AudioRecorderModel when amplitude changes 2023-12-05 19:01:17 +01:00
Myzel394
4d2d73d562
fix: Uncomment stopRecording on AudioRecorderService 2023-12-05 18:40:33 +01:00
Myzel394
699acb5311
fix: Destroy recording after stopping it 2023-12-05 18:39:05 +01:00
Myzel394
55e00c132a
fix: Allow sheet to be closed 2023-12-05 18:32:02 +01:00
Myzel394
add9c8cde5
feat: Add cameraID and enableAudio pass from UI to VideoRecorderService 2023-12-04 18:56:29 +01:00
Myzel394
60f53f3649
current stand 2023-12-04 16:23:59 +01:00
Myzel394
8a3bfcae4d
fix: Improve camera-info.kt and CamerasSelection 2023-12-04 09:27:28 +01:00
Myzel394
7f757f46f3
feat: Add permission check to camera and audio 2023-12-04 08:52:54 +01:00
Myzel394
22876c3be5
refactor: Rename package AudioRecorder -> RecorderScreen 2023-12-04 08:44:29 +01:00
Myzel394
40eee79aa3
feat: Adding camera preview functionality 2023-12-03 22:16:09 +01:00
Myzel394
817e9d96d0
feat: Add VideoRecorderPreparationSheet 2023-12-03 18:37:04 +01:00
Myzel394
e7e7505592
current stand 2023-12-02 19:10:44 +01:00
Myzel394
261753ad75
feat: Add AudioRecordingStart and VideoRecordingStart 2023-12-02 18:45:25 +01:00
Myzel394
e7989e2eba
feat: Add VideoRecorderQualityTile 2023-12-02 18:23:15 +01:00
Myzel394
7cf2e14df2
fix: Rename VideoFrameRate.kt -> VideoRecorderFrameRate.kt 2023-12-02 17:56:50 +01:00
Myzel394
b309d584a7
fix: Rename VideoFrameRate.kt -> VideoRecorderFrameRate.kt 2023-12-02 17:56:23 +01:00
Myzel394
b6d8bdf607
fix: Icon 2023-12-02 17:54:44 +01:00
Myzel394
e8d7b2b6f8
feat: Add VideoFrameRate 2023-12-02 17:50:54 +01:00
Myzel394
9598cd45fa
feat: Add VideoRecorderBitrateTile 2023-12-02 17:40:11 +01:00
Myzel394
4f265b23f8
feat(ui): Improve settings UI 2023-12-02 17:03:25 +01:00
Myzel394
d4a5612b77
chore(ui): Improve structure for settings 2023-12-02 16:47:11 +01:00
Myzel394
b98718214c
fix: Improve AppSettings structure 2023-12-02 16:34:15 +01:00
Myzel394
5f7c6a9140
fix: Close camera correctly 2023-12-02 16:03:03 +01:00
Myzel394
65be9a1e3e
chore: Move code for better readability 2023-12-01 00:29:41 +01:00
Myzel394
c40361aced
fix: Add pause method to VideoRecorderService 2023-12-01 00:20:23 +01:00
Myzel394
79b33ced2e
refactor: Small improvements for changeState in RecorderService 2023-12-01 00:18:04 +01:00
Myzel394
c38be920ec
feat: Add timeout to camera closing 2023-12-01 00:13:51 +01:00
Myzel394
569794d437
feat: Wait for camera to finish completely 2023-12-01 00:04:14 +01:00
Myzel394
3cee858b56
fix: Fix ffmpeg 2023-11-30 23:20:48 +01:00
Myzel394
0d618380fa
current stand: trying to improve concatenation of file 2023-11-29 19:02:32 +01:00
Myzel394
1be3912812
refactor: Improve BatchesFolder concatenation behavior 2023-11-29 18:31:53 +01:00
Myzel394
fe9d9d7298
debug: current stand trying to make ffmpeg to work 2023-11-28 18:59:01 +01:00
Myzel394
1eb7a7dd9a
fix: Remove unused AudioRecorderExporter.kt 2023-11-28 17:48:13 +01:00
Myzel394
8c391a7504
fix: Improve BatchesFolders; concatenate video files 2023-11-28 17:48:03 +01:00
Myzel394
89baa35ed7
fix: Properly assign batchesFolder 2023-11-28 17:43:31 +01:00
Myzel394
448108a974
feat: Improve settings for video recording 2023-11-27 15:03:27 +01:00
Myzel394
b3bb43367a
feat: Use VideoBatchesFolder and AudioBatchesFolder 2023-11-27 14:57:42 +01:00
Myzel394
3d17012fb7
fix: Use Unit for completer 2023-11-27 14:14:28 +01:00
Myzel394
92d1d6582a
feat: Added VideoRecorderSettings 2023-11-26 23:01:48 +01:00
Myzel394
f75a1a8a33
chore: Rename VideoService -> VideoRecorderService 2023-11-26 22:26:51 +01:00
Myzel394
a5a93cedc9
chore: Use general MediaConverter for concatenation 2023-11-26 22:23:39 +01:00
Myzel394
a9b6225717
feat: Add internal folder support 2023-11-26 22:06:49 +01:00
Myzel394
cb4f76efe0
chore: Cleanup 2023-11-26 20:02:19 +01:00
Myzel394
d9e707aeea
feat: Add VideoRecorderModel 2023-11-26 19:55:54 +01:00
Myzel394
f033550f8f
feat: Slowly creating workable camera support 2023-11-26 19:10:15 +01:00
Myzel394
d21580b0cb
chore: Improvements 2023-11-26 14:12:31 +01:00
Myzel394
f6bdd1b345
debug: Fix microphone not accessible in background 2023-11-25 17:13:28 +01:00
Myzel394
0de720ccf4
debug: Add PoC for recurring video 2023-11-25 15:29:42 +01:00
Myzel394
63198c316e
feat: Add poc 2023-11-25 14:30:34 +01:00
Myzel394
82116bc089
chore: Update dependencies 2023-11-23 22:17:23 +01:00
Myzel394
85c9009259
Merge pull request #50 from Myzel394/add-custom-location
Add custom location
2023-11-21 12:02:52 +01:00
Myzel394
b50cb91561
Merge pull request #53 from Myzel394/improve-backup
fix: Do not save batches on backup
2023-11-21 12:02:41 +01:00
Myzel394
275446818e
fix(docs): Make de-DE short_description shorter
Closes #49
2023-11-21 12:01:06 +01:00
Myzel394
3371600ebb
fix: Do not save batches on backup 2023-11-21 11:55:57 +01:00
Myzel394
e6ae9cbcb7
Merge pull request #52 from Myzel394/fix-crash
fix: Fix crash on invalid value input
2023-11-20 08:42:44 +01:00
Myzel394
2010d8ad94
fix: Fix crash on invalid value input 2023-11-20 08:36:33 +01:00
Myzel394
bc2e88ef04
feat: Check if recording is available on app resume 2023-11-19 19:40:26 +01:00
Myzel394
b6bfac4eee
fix: Properly check if recordings are available 2023-11-19 18:53:32 +01:00
Myzel394
da7251fc80
fix: remove println 2023-11-19 18:36:08 +01:00
Myzel394
89ac35e92e
fix: Delete custom saveFolder files after recording if enabled 2023-11-19 18:36:02 +01:00
Myzel394
d0701868cf
feat: Show better splitted path 2023-11-19 18:29:05 +01:00
Myzel394
703e783193
fix: Remove ForceExactMaxDuration 2023-11-19 17:51:04 +01:00
Myzel394
0364a79dcb
fix: Fix BatchesFolder export 2023-11-19 17:41:37 +01:00
Myzel394
18195c9893
fix: Fix custom BatchesFolder 2023-11-18 19:40:02 +01:00
Myzel394
79ba18630c
fix: Fixing audio recorder 2023-11-18 19:27:54 +01:00
Myzel394
6adff096d2
feat: Adding BatchesFolder 2023-11-18 18:15:10 +01:00
Myzel394
7722127796
chore: Cleanup 2023-11-17 21:48:19 +01:00
Myzel394
e94bfded6c
current stand 2023-11-17 17:40:33 +01:00
Myzel394
e237a5c99e
current stand: Debugging commands 2023-11-16 20:48:45 +01:00
Myzel394
d69bc7f4b1
current stand: Added symlinks 2023-11-05 23:04:27 +01:00
Myzel394
6948e11fca
fix: Properly take persistable uri for open document tree 2023-11-03 21:43:23 +01:00
Myzel394
8f8376cd16
current stand 2023-11-03 16:04:08 +01:00
Myzel394
47b85e74d2
current stand 2023-10-31 20:43:19 +01:00
Myzel394
c96c547484
Update README.md 2023-10-30 15:42:17 +01:00
Myzel394
0050b5b5c7
add google play badge 2023-10-30 15:41:28 +01:00
Myzel394
542170f189
feat: Improve SaveFolderTile folder preview 2023-10-30 14:20:41 +01:00
Myzel394
aa8fd2a37f
feat: Add SaveFolderTile 2023-10-30 14:02:02 +01:00
Myzel394
6605f44eec
feat: Add custom save folder to AppSettings 2023-10-30 12:38:39 +01:00
Myzel394
fd07d04502
fix: Improve recording; Closes #44 2023-10-28 10:56:29 +02:00
Myzel394
a69e2e2034
Merge remote-tracking branch 'origin/master' 2023-10-27 19:58:24 +02:00
Myzel394
7f4323c2a8
feat: Add usage-example.gif 2023-10-27 19:58:16 +02:00
Myzel394
ed4dc4dca3
chore: Add aab build support 2023-10-27 19:34:26 +02:00
Myzel394
42fbbf4d27
chore: Update version 2023-10-27 16:50:46 +02:00
Myzel394
66a54d8adf
Merge pull request #43 from Myzel394/delete-recordings-immediately-and-fix-dir
Delete recordings immediately and fix dir
2023-10-27 15:53:13 +02:00
Myzel394
dd58ce23a9
Merge pull request #42 from Myzel394/delete-recordings-immediately-and-fix-dir
Delete recordings immediately and fix dir
2023-10-26 20:11:41 +02:00
Myzel394
68438a6a0b
refactor: Move settings out of SettingsScreen's atoms 2023-10-26 20:08:58 +02:00
Myzel394
ca33e57069
chore(ci-cd): Improve name for google play 2023-10-26 20:00:22 +02:00
Myzel394
558aa2c86f
fix: Fix recording deletions 2023-10-26 19:56:52 +02:00
Myzel394
b2a413f609
refactor: Rename AudioRecorder.kt -> AudioRecorderScreen 2023-10-26 19:42:18 +02:00
Myzel394
eacf3cc2f1
fix: Move lastRecording to AppSettings 2023-10-26 19:41:13 +02:00
Myzel394
369050e94e
fix: Properly clear recordings when deleteRecordingsImmediately is set 2023-10-26 19:12:32 +02:00
Myzel394
6b6a19ead3
feat: Add DeleteRecordingsImmediately to settings 2023-10-26 18:50:15 +02:00
Myzel394
d6b9f56a60
Merge remote-tracking branch 'origin/master' 2023-10-26 18:35:26 +02:00
Myzel394
9dc1c05d69
feat: Use internal directory for saving files 2023-10-26 18:27:10 +02:00
Myzel394
bfe8e8f844
Merge pull request #23 from Myzel394/add-selection
Add microphone selection
2023-10-26 17:31:58 +02:00
Myzel394
517516518f
Merge pull request #40 from Myzel394/add-about-section
Add about section
2023-10-26 17:28:26 +02:00
Myzel394
3d7f053e8b
feat: Add GITHUB_SPONSORS_URL 2023-10-26 16:43:31 +02:00
Myzel394
cc3b0736c9
fix: Improve DonationsTile 2023-10-26 16:36:16 +02:00
Myzel394
e7bc1ac0b3
current stand 2023-10-26 15:54:30 +02:00
Myzel394
b6d0501c85
feat: Add DonationsTile 2023-10-25 18:59:23 +02:00
Myzel394
fe9d2ef1e6
feat: Add gpg key section to AboutScreen 2023-10-25 18:44:02 +02:00
Myzel394
a024ef9154
feat: Improving AboutScreen 2023-10-25 18:19:40 +02:00
Myzel394
d6a95c4ce1
current stand 2023-10-25 15:44:05 +02:00
Myzel394
8c77376fa6
feat: Added basic AboutScreen 2023-10-24 20:37:15 +02:00
Myzel394
e11c8c28f2
feat: Add AboutTile 2023-10-24 20:23:38 +02:00
Myzel394
b12aeaeec8
Merge pull request #36 from Myzel394/fix-lag
Fix lag
2023-10-24 19:54:27 +02:00
Myzel394
ac544a9b8e
feat: Add delay to show dialog 2023-10-24 19:24:47 +02:00
Myzel394
1029ff4e96
feat: Add support for more crypto 2023-10-24 18:10:32 +02:00
Myzel394
584f4fc216
fix: Properly limit amplitudes 2023-10-24 16:28:17 +02:00
Myzel394
0130962351
fix: Move ShowAllMicrophonesTile down 2023-10-24 15:56:17 +02:00
Myzel394
2c58ab3c18
Merge branch 'master' into add-selection
# Conflicts:
#	app/src/main/java/app/myzel394/alibi/services/RecorderService.kt
#	app/src/main/java/app/myzel394/alibi/ui/components/atoms/MessageBox.kt
#	app/src/main/java/app/myzel394/alibi/ui/models/AudioRecorderModel.kt
#	app/src/main/java/app/myzel394/alibi/ui/screens/AudioRecorder.kt
2023-10-24 15:53:38 +02:00
Myzel394
8c45a863e7
Merge pull request #33 from Myzel394/add-notification-selection
Add notification selection
2023-10-24 15:49:27 +02:00
Myzel394
16e6fd8fc2
fix: Improve code 2023-10-24 15:49:15 +02:00
Myzel394
cb43a1b371
chore: Adapt to new foreground service 2023-10-24 15:43:51 +02:00
Myzel394
f80d3cbe16
fix(ci-cd): Upload all APKs 2023-10-24 14:15:01 +02:00
Myzel394
c6d20c74f2
fix: Properly set on going for notifications 2023-10-24 14:11:56 +02:00
Myzel394
573a70cca8
fix: Fix padding 2023-10-24 14:02:37 +02:00
Myzel394
26f17869d6
feat: Add message info to NotificationEditor 2023-10-24 12:50:58 +02:00
Myzel394
37a8a9871e
feat: Add custom notification support 2023-10-24 12:47:46 +02:00
Myzel394
81520c48ba
refactor: Use RecorderNotificationHelper for notifications 2023-10-24 12:02:30 +02:00
Myzel394
1efb57d540
feat: Improve animation 2023-10-24 11:40:04 +02:00
Myzel394
f68baaf1bf
feat: Set NotificationSettings to null if using default preset 2023-10-24 11:34:47 +02:00
Myzel394
6ad7ff12e6
feat: Properly update notification settings 2023-10-24 11:31:01 +02:00
Myzel394
f1296c32ec
refactor: Migrating to NotificationViewModel 2023-10-24 10:48:15 +02:00
Myzel394
15bb9b4051
feat: Improve CustomRecordingNotificationsScreen behavior; Add save support (wip) 2023-10-24 10:22:05 +02:00
Myzel394
d0885ba877
feat: Improve CustomRecordingNotificationsScreen layout 2023-10-24 10:10:40 +02:00
Myzel394
54ad067cdf
feat: Add showOngoing button 2023-10-23 21:58:40 +02:00
Myzel394
5a55619e55
feat: Make presets workable 2023-10-23 21:46:41 +02:00
Myzel394
dd57ce513e
feat: Add NotificationPresetSelect; Add more presets 2023-10-23 21:29:07 +02:00
Myzel394
119782fb8f
feat: Adding EditNotificationInput 2023-10-23 20:51:50 +02:00
Myzel394
17a52fdcb5
feat: Add LandingElement for CustomRecordingNotificationsScreen 2023-10-23 19:17:42 +02:00
Myzel394
1e5806000a
feat: Add basic CustomRecordingNotificationsScreen 2023-10-23 18:39:38 +02:00
Myzel394
ac4102d5cd
feat: Add CustomNotificationTile.kt to SettingsScreen 2023-10-23 18:22:49 +02:00
Myzel394
d0d8996227
feat: Add support for custom notification 2023-10-23 18:10:24 +02:00
Myzel394
6ef60942e0
fix: Use built in serializer to parse AppSettings 2023-10-23 16:55:42 +02:00
Myzel394
01a6f49b77
feat: Add NotificationSettings 2023-10-23 12:50:27 +02:00
Myzel394
dbec10c96e
merge dev 2023-10-23 12:24:37 +02:00
Myzel394
fe443d7435
Merge pull request #32 from Myzel394/l10n_weblate
New Crowdin updates
2023-10-22 22:56:46 +02:00
Myzel394
0885a54770
fix: Improve grammar 2023-10-22 22:52:28 +02:00
Myzel394
30f5ef34a3
fix: Cleanup 2023-10-22 22:42:32 +02:00
Myzel394
2c90347748
fix: Improve grammar 2023-10-22 22:35:35 +02:00
Myzel394
5b85c07e4b
Merge branch 'master' into l10n_weblate
# Conflicts:
#	app/src/main/res/values-de-DE/strings.xml
#	app/src/main/res/values-de/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-tr-TR/strings.xml
#	app/src/main/res/values-tr/strings.xml
#	app/src/main/res/values-zh-CN/strings.xml
#	app/src/main/res/values-zh/strings.xml
2023-10-22 22:33:46 +02:00
Myzel394
a8d93e0f6a
fix: Fix strings 2023-10-22 22:31:58 +02:00
Myzel394
7fb8fba161 New translations short_description.txt (German) 2023-10-22 22:11:01 +02:00
Myzel394
cf5ebc84af New translations full_description.txt (German) 2023-10-22 22:10:50 +02:00
Myzel394
89e2212eec New translations strings.xml (Vietnamese) 2023-10-22 22:10:46 +02:00
Myzel394
4c3cf19d31 New translations strings.xml (English) 2023-10-22 22:10:46 +02:00
Myzel394
fa8ef46a4f New translations strings.xml (Chinese Simplified) 2023-10-22 22:10:45 +02:00
Myzel394
784988897c New translations strings.xml (Ukrainian) 2023-10-22 22:10:44 +02:00
Myzel394
8b91e64a82 New translations strings.xml (Turkish) 2023-10-22 22:10:43 +02:00
Myzel394
9903f2984b New translations strings.xml (Swedish) 2023-10-22 22:10:42 +02:00
Myzel394
e2a9c3126d New translations strings.xml (Serbian (Cyrillic)) 2023-10-22 22:10:42 +02:00
Myzel394
16686b2d04 New translations strings.xml (Russian) 2023-10-22 22:10:41 +02:00
Myzel394
be3255e1a1 New translations strings.xml (Portuguese) 2023-10-22 22:10:41 +02:00
Myzel394
f94a588fe4 New translations strings.xml (Polish) 2023-10-22 22:10:40 +02:00
Myzel394
f49159f4a6 New translations strings.xml (Norwegian) 2023-10-22 22:10:39 +02:00
Myzel394
f6636854a8 New translations strings.xml (Dutch) 2023-10-22 22:10:39 +02:00
Myzel394
f608fce478 New translations strings.xml (Korean) 2023-10-22 22:10:38 +02:00
Myzel394
3927d89544 New translations strings.xml (Japanese) 2023-10-22 22:10:38 +02:00
Myzel394
d1ef07e552 New translations strings.xml (Italian) 2023-10-22 22:10:37 +02:00
Myzel394
ee7d3bedf3 New translations strings.xml (Hungarian) 2023-10-22 22:10:37 +02:00
Myzel394
a59fa61385 New translations strings.xml (Hebrew) 2023-10-22 22:10:36 +02:00
Myzel394
23dbcc2e25 New translations strings.xml (Finnish) 2023-10-22 22:10:35 +02:00
Myzel394
b151d324e7 New translations strings.xml (Greek) 2023-10-22 22:10:35 +02:00
Myzel394
676b226c7c New translations strings.xml (German) 2023-10-22 22:10:34 +02:00
Myzel394
a0f977416c New translations strings.xml (Danish) 2023-10-22 22:10:33 +02:00
Myzel394
87b1c27acb New translations strings.xml (Czech) 2023-10-22 22:10:33 +02:00
Myzel394
5585e79735 New translations strings.xml (Catalan) 2023-10-22 22:10:32 +02:00
Myzel394
9ad2ab8372 New translations strings.xml (Arabic) 2023-10-22 22:10:31 +02:00
Myzel394
d50e20e041 New translations strings.xml (Afrikaans) 2023-10-22 22:10:30 +02:00
Myzel394
043c86dbef New translations strings.xml (Spanish) 2023-10-22 22:10:30 +02:00
Myzel394
a3fba67786 New translations strings.xml (French) 2023-10-22 22:10:29 +02:00
Myzel394
3c593caae7 New translations strings.xml (Romanian) 2023-10-22 22:10:29 +02:00
Myzel394
584fa59d58
fix: Fix crowdin 2023-10-22 21:37:08 +02:00
Myzel394
78d33ad9a9
feat: Cleanup 2023-10-22 21:31:05 +02:00
Myzel394
4488e123f4
feat: Add link to crowdin 2023-10-22 21:10:17 +02:00
Myzel394
c2586c37a2
fix: Cleanup 2023-10-22 20:57:11 +02:00
Myzel394
47f5f047ed
Merge pull request #30 from Myzel394/l10n_weblate
New Crowdin updates
2023-10-22 20:55:16 +02:00
Myzel394
daa5e6d1f0
Merge branch 'master' into l10n_weblate 2023-10-22 20:55:07 +02:00
Myzel394
07d043a143 New translations strings.xml (Portuguese, Brazilian) 2023-10-22 20:48:43 +02:00
Myzel394
44cbc30254 New translations strings.xml (Vietnamese) 2023-10-22 20:48:42 +02:00
Myzel394
980d7895ca New translations strings.xml (English) 2023-10-22 20:48:41 +02:00
Myzel394
7245b9caf6 New translations strings.xml (Chinese Traditional) 2023-10-22 20:48:41 +02:00
Myzel394
9ce62867c4 New translations strings.xml (Chinese Simplified) 2023-10-22 20:48:40 +02:00
Myzel394
fc2f3ae772 New translations strings.xml (Ukrainian) 2023-10-22 20:48:40 +02:00
Myzel394
8ac210a4bd New translations strings.xml (Turkish) 2023-10-22 20:48:39 +02:00
Myzel394
186240fbb4 New translations strings.xml (Swedish) 2023-10-22 20:48:38 +02:00
Myzel394
7857266778 New translations strings.xml (Serbian (Cyrillic)) 2023-10-22 20:48:38 +02:00
Myzel394
75c5191f98 New translations strings.xml (Russian) 2023-10-22 20:48:37 +02:00
Myzel394
8f3531de06 New translations strings.xml (Portuguese) 2023-10-22 20:48:37 +02:00
Myzel394
b343d4a085 New translations strings.xml (Polish) 2023-10-22 20:48:36 +02:00
Myzel394
ac0fd14a17 New translations strings.xml (Norwegian) 2023-10-22 20:48:35 +02:00
Myzel394
e8d51896c5 New translations strings.xml (Dutch) 2023-10-22 20:48:35 +02:00
Myzel394
670aed94ab New translations strings.xml (Korean) 2023-10-22 20:48:34 +02:00
Myzel394
f0fc844066 New translations strings.xml (Japanese) 2023-10-22 20:48:34 +02:00
Myzel394
cf42dc12e9 New translations strings.xml (Italian) 2023-10-22 20:48:33 +02:00
Myzel394
75168b30fb New translations strings.xml (Hungarian) 2023-10-22 20:48:33 +02:00
Myzel394
d851a7ce7b New translations strings.xml (Hebrew) 2023-10-22 20:48:32 +02:00
Myzel394
8c367ea4c0 New translations strings.xml (Finnish) 2023-10-22 20:48:32 +02:00
Myzel394
4f567d921a New translations strings.xml (Greek) 2023-10-22 20:48:31 +02:00
Myzel394
4c6a6a2584 New translations strings.xml (German) 2023-10-22 20:48:31 +02:00
Myzel394
ba385700c0 New translations strings.xml (Danish) 2023-10-22 20:48:30 +02:00
Myzel394
5b6488b814 New translations strings.xml (Czech) 2023-10-22 20:48:29 +02:00
Myzel394
388f99e7f6 New translations strings.xml (Catalan) 2023-10-22 20:48:29 +02:00
Myzel394
e37aeee765 New translations strings.xml (Arabic) 2023-10-22 20:48:28 +02:00
Myzel394
22f7464040 New translations strings.xml (Afrikaans) 2023-10-22 20:48:28 +02:00
Myzel394
e8ff64d4ab New translations strings.xml (Spanish) 2023-10-22 20:48:27 +02:00
Myzel394
5cfe3e9181 New translations strings.xml (French) 2023-10-22 20:48:27 +02:00
Myzel394
92fc939da4 New translations strings.xml (Romanian) 2023-10-22 20:48:26 +02:00
Myzel394
8fc0dbe6a3
Update crowdin.yml 2023-10-22 20:48:02 +02:00
Myzel394
3c1244abe6 New translations strings.xml (Portuguese, Brazilian) 2023-10-22 20:22:30 +02:00
Myzel394
1526ee6f4b New translations strings.xml (Vietnamese) 2023-10-22 20:22:30 +02:00
Myzel394
3976179e64 New translations strings.xml (English) 2023-10-22 20:22:29 +02:00
Myzel394
26e0f21911 New translations strings.xml (Chinese Traditional) 2023-10-22 20:22:29 +02:00
Myzel394
2017dff461 New translations strings.xml (Chinese Simplified) 2023-10-22 20:22:28 +02:00
Myzel394
58e544aabe New translations strings.xml (Ukrainian) 2023-10-22 20:22:28 +02:00
Myzel394
bd0eae46f1 New translations strings.xml (Turkish) 2023-10-22 20:22:27 +02:00
Myzel394
f129ed92f0 New translations strings.xml (Swedish) 2023-10-22 20:22:26 +02:00
Myzel394
18f2181966 New translations strings.xml (Serbian (Cyrillic)) 2023-10-22 20:22:26 +02:00
Myzel394
315adabebc New translations strings.xml (Russian) 2023-10-22 20:22:25 +02:00
Myzel394
a7f6bb2855 New translations strings.xml (Portuguese) 2023-10-22 20:22:25 +02:00
Myzel394
8cb9645db4 New translations strings.xml (Polish) 2023-10-22 20:22:24 +02:00
Myzel394
595905d02d New translations strings.xml (Norwegian) 2023-10-22 20:22:24 +02:00
Myzel394
768445bfbf New translations strings.xml (Dutch) 2023-10-22 20:22:23 +02:00
Myzel394
8f443a2770 New translations strings.xml (Korean) 2023-10-22 20:22:23 +02:00
Myzel394
19f092bf2a New translations strings.xml (Japanese) 2023-10-22 20:22:22 +02:00
Myzel394
09d5fb728e New translations strings.xml (Italian) 2023-10-22 20:22:22 +02:00
Myzel394
7bd4a94042 New translations strings.xml (Hungarian) 2023-10-22 20:22:21 +02:00
Myzel394
9a714af6e1 New translations strings.xml (Hebrew) 2023-10-22 20:22:21 +02:00
Myzel394
56c379b607 New translations strings.xml (Finnish) 2023-10-22 20:22:20 +02:00
Myzel394
c94a69e43e New translations strings.xml (Greek) 2023-10-22 20:22:19 +02:00
Myzel394
2ab015d928 New translations strings.xml (German) 2023-10-22 20:22:19 +02:00
Myzel394
f25ac075e7 New translations strings.xml (Danish) 2023-10-22 20:22:18 +02:00
Myzel394
665ba94410 New translations strings.xml (Czech) 2023-10-22 20:22:18 +02:00
Myzel394
f989604e8b New translations strings.xml (Catalan) 2023-10-22 20:22:17 +02:00
Myzel394
82f81b4ec8 New translations strings.xml (Arabic) 2023-10-22 20:22:17 +02:00
Myzel394
46cce55132 New translations strings.xml (Afrikaans) 2023-10-22 20:22:16 +02:00
Myzel394
378e81caee New translations strings.xml (Spanish) 2023-10-22 20:22:16 +02:00
Myzel394
91d7f7c8c1 New translations strings.xml (French) 2023-10-22 20:22:15 +02:00
Myzel394
e7fb2b54a0 New translations strings.xml (Romanian) 2023-10-22 20:22:14 +02:00
Myzel394
2134839c30
Update crowdin.yml 2023-10-22 20:21:50 +02:00
Myzel394
4dbc9fa8eb New translations strings.xml (Portuguese, Brazilian) 2023-10-22 20:17:14 +02:00
Myzel394
48d6663a96 New translations strings.xml (Vietnamese) 2023-10-22 20:17:12 +02:00
Myzel394
b0510841c7 New translations strings.xml (English) 2023-10-22 20:17:11 +02:00
Myzel394
0d7a82930b New translations strings.xml (Chinese Traditional) 2023-10-22 20:17:11 +02:00
Myzel394
1c0b816786 New translations strings.xml (Chinese Simplified) 2023-10-22 20:17:10 +02:00
Myzel394
e3ed1e5418 New translations strings.xml (Ukrainian) 2023-10-22 20:17:09 +02:00
Myzel394
4613b19794 New translations strings.xml (Turkish) 2023-10-22 20:17:08 +02:00
Myzel394
f928aefe24 New translations strings.xml (Swedish) 2023-10-22 20:17:07 +02:00
Myzel394
21cd9b059b New translations strings.xml (Serbian (Cyrillic)) 2023-10-22 20:17:06 +02:00
Myzel394
b5dc7cf945 New translations strings.xml (Russian) 2023-10-22 20:17:06 +02:00
Myzel394
5a984c686b New translations strings.xml (Portuguese) 2023-10-22 20:17:05 +02:00
Myzel394
8cf558098b New translations strings.xml (Polish) 2023-10-22 20:17:04 +02:00
Myzel394
21811b39ef New translations strings.xml (Norwegian) 2023-10-22 20:17:03 +02:00
Myzel394
e5bbffbcaa New translations strings.xml (Dutch) 2023-10-22 20:17:02 +02:00
Myzel394
767d56354a New translations strings.xml (Korean) 2023-10-22 20:17:01 +02:00
Myzel394
fd01b491d6 New translations strings.xml (Japanese) 2023-10-22 20:17:00 +02:00
Myzel394
222883c984 New translations strings.xml (Italian) 2023-10-22 20:17:00 +02:00
Myzel394
ffd002f3ed New translations strings.xml (Hungarian) 2023-10-22 20:16:59 +02:00
Myzel394
4847271ea9 New translations strings.xml (Hebrew) 2023-10-22 20:16:58 +02:00
Myzel394
322c09c90a New translations strings.xml (Finnish) 2023-10-22 20:16:57 +02:00
Myzel394
f5549b49f9 New translations strings.xml (Greek) 2023-10-22 20:16:56 +02:00
Myzel394
25bc10cc97 New translations strings.xml (German) 2023-10-22 20:16:56 +02:00
Myzel394
543ac0be58 New translations strings.xml (Danish) 2023-10-22 20:16:55 +02:00
Myzel394
2181609169 New translations strings.xml (Czech) 2023-10-22 20:16:54 +02:00
Myzel394
56139b5f3e New translations strings.xml (Catalan) 2023-10-22 20:16:54 +02:00
Myzel394
9c1d263917 New translations strings.xml (Arabic) 2023-10-22 20:16:53 +02:00
Myzel394
7e90ae1f20 New translations strings.xml (Afrikaans) 2023-10-22 20:16:52 +02:00
Myzel394
ef91de0d4c New translations strings.xml (Spanish) 2023-10-22 20:16:51 +02:00
Myzel394
9d58bd4163 New translations strings.xml (French) 2023-10-22 20:16:50 +02:00
Myzel394
c46aafdeb0 New translations strings.xml (Romanian) 2023-10-22 20:16:50 +02:00
Myzel394
de0e163b28
Update crowdin.yml 2023-10-22 20:15:59 +02:00
Myzel394
739d5cc738
fix: Fix translation names 2023-10-22 20:13:44 +02:00
Myzel394
7fe6ab79e1
fix: Fix Turkish translation name 2023-10-22 20:00:37 +02:00
Myzel394
756323fcdf New translations short_description.txt (Portuguese, Brazilian) 2023-10-22 18:13:40 +02:00
Myzel394
f1ef9b3069 New translations short_description.txt (Vietnamese) 2023-10-22 18:13:39 +02:00
Myzel394
20457d0308 New translations short_description.txt (Chinese Traditional) 2023-10-22 18:13:38 +02:00
Myzel394
3b207f6903 New translations short_description.txt (Chinese Simplified) 2023-10-22 18:13:37 +02:00
Myzel394
6b7a8eb654 New translations short_description.txt (Ukrainian) 2023-10-22 18:13:37 +02:00
Myzel394
db6bdf71fe New translations short_description.txt (Turkish) 2023-10-22 18:13:36 +02:00
Myzel394
5f8063090e New translations short_description.txt (Swedish) 2023-10-22 18:13:35 +02:00
Myzel394
82b17b39e6 New translations short_description.txt (Serbian (Cyrillic)) 2023-10-22 18:13:35 +02:00
Myzel394
8a5a98e092 New translations short_description.txt (Russian) 2023-10-22 18:13:34 +02:00
Myzel394
aab39eca8e New translations short_description.txt (Portuguese) 2023-10-22 18:13:33 +02:00
Myzel394
35f1ea6a3a New translations short_description.txt (Polish) 2023-10-22 18:13:32 +02:00
Myzel394
90e1a0937a New translations short_description.txt (Norwegian) 2023-10-22 18:13:31 +02:00
Myzel394
354b3dc41c New translations short_description.txt (Dutch) 2023-10-22 18:13:31 +02:00
Myzel394
e282488670 New translations short_description.txt (Korean) 2023-10-22 18:13:30 +02:00
Myzel394
6f5a5352cc New translations short_description.txt (Japanese) 2023-10-22 18:13:29 +02:00
Myzel394
20da4acc04 New translations short_description.txt (Italian) 2023-10-22 18:13:28 +02:00
Myzel394
889525bceb New translations short_description.txt (Hungarian) 2023-10-22 18:13:27 +02:00
Myzel394
81592a2df9 New translations short_description.txt (Hebrew) 2023-10-22 18:13:27 +02:00
Myzel394
93ab1fcda4 New translations short_description.txt (Finnish) 2023-10-22 18:13:26 +02:00
Myzel394
8481946123 New translations short_description.txt (Greek) 2023-10-22 18:13:25 +02:00
Myzel394
43bf2f0c01 New translations short_description.txt (German) 2023-10-22 18:13:25 +02:00
Myzel394
98f335c9bf New translations short_description.txt (Danish) 2023-10-22 18:13:24 +02:00
Myzel394
f64e7bac6c New translations short_description.txt (Czech) 2023-10-22 18:13:23 +02:00
Myzel394
a09b146b7a New translations short_description.txt (Catalan) 2023-10-22 18:13:23 +02:00
Myzel394
37c8e131ed New translations short_description.txt (Arabic) 2023-10-22 18:13:22 +02:00
Myzel394
76fca329b7 New translations short_description.txt (Afrikaans) 2023-10-22 18:13:21 +02:00
Myzel394
4889a49d1b New translations short_description.txt (Spanish) 2023-10-22 18:13:20 +02:00
Myzel394
ea249b0b4f New translations short_description.txt (French) 2023-10-22 18:13:19 +02:00
Myzel394
56b6c01b71 New translations short_description.txt (Romanian) 2023-10-22 18:13:19 +02:00
Myzel394
e670c4b91a New translations full_description.txt (Portuguese, Brazilian) 2023-10-22 18:13:18 +02:00
Myzel394
d57a14a2cb New translations full_description.txt (Vietnamese) 2023-10-22 18:13:17 +02:00
Myzel394
edd7690c48 New translations full_description.txt (Chinese Traditional) 2023-10-22 18:13:16 +02:00
Myzel394
c31361e607 New translations full_description.txt (Chinese Simplified) 2023-10-22 18:13:15 +02:00
Myzel394
ed48865985 New translations full_description.txt (Ukrainian) 2023-10-22 18:13:14 +02:00
Myzel394
6de6e8e9ae New translations full_description.txt (Turkish) 2023-10-22 18:13:14 +02:00
Myzel394
dabfff1e30 New translations full_description.txt (Swedish) 2023-10-22 18:13:13 +02:00
Myzel394
876e51f641 New translations full_description.txt (Serbian (Cyrillic)) 2023-10-22 18:13:12 +02:00
Myzel394
9c19a26775 New translations full_description.txt (Russian) 2023-10-22 18:13:11 +02:00
Myzel394
8f98776325 New translations full_description.txt (Portuguese) 2023-10-22 18:13:11 +02:00
Myzel394
ba291781da New translations full_description.txt (Polish) 2023-10-22 18:13:10 +02:00
Myzel394
35c90a6edb New translations full_description.txt (Norwegian) 2023-10-22 18:13:09 +02:00
Myzel394
778c73cdff New translations full_description.txt (Dutch) 2023-10-22 18:13:08 +02:00
Myzel394
f4a7b0657d New translations full_description.txt (Korean) 2023-10-22 18:13:07 +02:00
Myzel394
08641e4b7b New translations full_description.txt (Japanese) 2023-10-22 18:13:06 +02:00
Myzel394
0696eaf3ae New translations full_description.txt (Italian) 2023-10-22 18:13:06 +02:00
Myzel394
95a6f492ac New translations full_description.txt (Hungarian) 2023-10-22 18:13:05 +02:00
Myzel394
1b4938fab1 New translations full_description.txt (Hebrew) 2023-10-22 18:13:04 +02:00
Myzel394
f22773757a New translations full_description.txt (Finnish) 2023-10-22 18:13:03 +02:00
Myzel394
70b4e4b75c New translations full_description.txt (Greek) 2023-10-22 18:13:02 +02:00
Myzel394
6406e495bd New translations full_description.txt (German) 2023-10-22 18:13:01 +02:00
Myzel394
ef41fd03fc New translations full_description.txt (Danish) 2023-10-22 18:13:00 +02:00
Myzel394
852320c1b0 New translations full_description.txt (Czech) 2023-10-22 18:12:59 +02:00
Myzel394
dbbf19e195 New translations full_description.txt (Catalan) 2023-10-22 18:12:59 +02:00
Myzel394
e6b416a7b5 New translations full_description.txt (Arabic) 2023-10-22 18:12:58 +02:00
Myzel394
48949fd8ac New translations full_description.txt (Afrikaans) 2023-10-22 18:12:57 +02:00
Myzel394
00ec3b8db1 New translations full_description.txt (Spanish) 2023-10-22 18:12:56 +02:00
Myzel394
6158baca38 New translations full_description.txt (French) 2023-10-22 18:12:55 +02:00
Myzel394
4a17ed7fdf New translations full_description.txt (Romanian) 2023-10-22 18:12:54 +02:00
Myzel394
8e8671ba31 New translations strings.xml (Vietnamese) 2023-10-22 18:12:53 +02:00
Myzel394
babea7fbc0 New translations strings.xml (English) 2023-10-22 18:12:52 +02:00
Myzel394
6c935f7f96 New translations strings.xml (Chinese Simplified) 2023-10-22 18:12:51 +02:00
Myzel394
df7c7bd652 New translations strings.xml (Ukrainian) 2023-10-22 18:12:50 +02:00
Myzel394
8f2ec76d87 New translations strings.xml (Turkish) 2023-10-22 18:12:49 +02:00
Myzel394
0954881e3d New translations strings.xml (Swedish) 2023-10-22 18:12:48 +02:00
Myzel394
169d912a3f New translations strings.xml (Serbian (Cyrillic)) 2023-10-22 18:12:48 +02:00
Myzel394
bbdda94cd8 New translations strings.xml (Russian) 2023-10-22 18:12:47 +02:00
Myzel394
648dde28d4 New translations strings.xml (Portuguese) 2023-10-22 18:12:46 +02:00
Myzel394
0618036b5c New translations strings.xml (Polish) 2023-10-22 18:12:45 +02:00
Myzel394
f61f9ab9a0 New translations strings.xml (Norwegian) 2023-10-22 18:12:44 +02:00
Myzel394
bf2fda73d4 New translations strings.xml (Dutch) 2023-10-22 18:12:43 +02:00
Myzel394
a82fa6d341 New translations strings.xml (Korean) 2023-10-22 18:12:42 +02:00
Myzel394
9f988f4ca8 New translations strings.xml (Japanese) 2023-10-22 18:12:42 +02:00
Myzel394
a3c606b26b New translations strings.xml (Italian) 2023-10-22 18:12:41 +02:00
Myzel394
2ca4559b77 New translations strings.xml (Hungarian) 2023-10-22 18:12:40 +02:00
Myzel394
aa8f56aced New translations strings.xml (Hebrew) 2023-10-22 18:12:39 +02:00
Myzel394
a6c68b41b5 New translations strings.xml (Finnish) 2023-10-22 18:12:38 +02:00
Myzel394
c88503039f New translations strings.xml (Greek) 2023-10-22 18:12:37 +02:00
Myzel394
48d4375542 New translations strings.xml (German) 2023-10-22 18:12:36 +02:00
Myzel394
0dbabba785 New translations strings.xml (Danish) 2023-10-22 18:12:36 +02:00
Myzel394
a85ee49f1a New translations strings.xml (Czech) 2023-10-22 18:12:35 +02:00
Myzel394
f2283294b9 New translations strings.xml (Catalan) 2023-10-22 18:12:34 +02:00
Myzel394
2a1b24f4d7 New translations strings.xml (Arabic) 2023-10-22 18:12:33 +02:00
Myzel394
e77a1516d2 New translations strings.xml (Afrikaans) 2023-10-22 18:12:32 +02:00
Myzel394
c05da6075a New translations strings.xml (Spanish) 2023-10-22 18:12:31 +02:00
Myzel394
b6aff4abcc New translations strings.xml (French) 2023-10-22 18:12:30 +02:00
Myzel394
26f1634a88 New translations strings.xml (Romanian) 2023-10-22 18:12:30 +02:00
Myzel394
f90ca6ef84
Update crowdin.yml 2023-10-22 18:11:58 +02:00
Myzel394
cfaaa02e44 New translations strings.xml (Portuguese, Brazilian) 2023-10-22 18:08:12 +02:00
Myzel394
3cb6c39a70 New translations strings.xml (Vietnamese) 2023-10-22 18:08:11 +02:00
Myzel394
f8788d4e3f New translations strings.xml (English) 2023-10-22 18:08:10 +02:00
Myzel394
3dbdab09e6 New translations strings.xml (Chinese Traditional) 2023-10-22 18:08:10 +02:00
Myzel394
37981cebd1 New translations strings.xml (Chinese Simplified) 2023-10-22 18:08:09 +02:00
Myzel394
eca7e00428 New translations strings.xml (Ukrainian) 2023-10-22 18:08:08 +02:00
Myzel394
a2c4d935a6 New translations strings.xml (Turkish) 2023-10-22 18:08:07 +02:00
Myzel394
8fed6c068b New translations strings.xml (Swedish) 2023-10-22 18:08:06 +02:00
Myzel394
8248ab7f68 New translations strings.xml (Serbian (Cyrillic)) 2023-10-22 18:08:05 +02:00
Myzel394
faa4bb5269 New translations strings.xml (Russian) 2023-10-22 18:08:05 +02:00
Myzel394
ab137370aa New translations strings.xml (Portuguese) 2023-10-22 18:08:04 +02:00
Myzel394
c9fa72a744 New translations strings.xml (Polish) 2023-10-22 18:08:03 +02:00
Myzel394
a9f528eef0 New translations strings.xml (Norwegian) 2023-10-22 18:08:02 +02:00
Myzel394
607f2ad2c5 New translations strings.xml (Dutch) 2023-10-22 18:08:01 +02:00
Myzel394
9c84e86215 New translations strings.xml (Korean) 2023-10-22 18:08:00 +02:00
Myzel394
9ba61dea67 New translations strings.xml (Japanese) 2023-10-22 18:08:00 +02:00
Myzel394
ea5b15175e New translations strings.xml (Italian) 2023-10-22 18:07:59 +02:00
Myzel394
b1775d46d8 New translations strings.xml (Hungarian) 2023-10-22 18:07:58 +02:00
Myzel394
0b7d312170 New translations strings.xml (Hebrew) 2023-10-22 18:07:57 +02:00
Myzel394
2bf43c21da New translations strings.xml (Finnish) 2023-10-22 18:07:57 +02:00
Myzel394
b918d2061b New translations strings.xml (Greek) 2023-10-22 18:07:56 +02:00
Myzel394
31d573ad89 New translations strings.xml (German) 2023-10-22 18:07:55 +02:00
Myzel394
92b9e85540 New translations strings.xml (Danish) 2023-10-22 18:07:54 +02:00
Myzel394
4aa53962b6 New translations strings.xml (Czech) 2023-10-22 18:07:53 +02:00
Myzel394
336efcfe03 New translations strings.xml (Catalan) 2023-10-22 18:07:52 +02:00
Myzel394
97ec04f579 New translations strings.xml (Arabic) 2023-10-22 18:07:52 +02:00
Myzel394
1460a8ff5b New translations strings.xml (Afrikaans) 2023-10-22 18:07:51 +02:00
Myzel394
b5900159d3 New translations strings.xml (Spanish) 2023-10-22 18:07:50 +02:00
Myzel394
0219c88a40 New translations strings.xml (French) 2023-10-22 18:07:49 +02:00
Myzel394
a4a6f85e75 New translations strings.xml (Romanian) 2023-10-22 18:07:49 +02:00
Myzel394
4ba0c64f54
Merge pull request #29 from Myzel394/add-import-export
Add import export
2023-10-22 18:06:51 +02:00
Myzel394
543f06eee7
chore: Add crowdin.yml 2023-10-22 18:03:47 +02:00
Myzel394
54b2e9bee5
fix: Improve snackbar & add import success message 2023-10-22 17:05:24 +02:00
Myzel394
5e9f46d979
feat: Add import functionality 2023-10-22 16:47:43 +02:00
Myzel394
d9420ddff5
feat: Add import export to settings 2023-10-22 16:14:45 +02:00
Myzel394
eceaba78be
feat: Add json serializability for AppSettings 2023-10-22 16:07:33 +02:00
Myzel394
41af9004a2
feat: Increase max duration to 10 days; Closes #25 2023-10-22 15:25:32 +02:00
Myzel394
3345ee6eb8
Merge pull request #24 from Myzel394/add-theme-selsection
Add theme selection
2023-10-22 15:21:36 +02:00
Myzel394
c994a47d8a
fix: Improve ThemeSelector 2023-10-22 15:02:28 +02:00
Myzel394
0fb341ffb9
feat: Add support for old theme selection 2023-10-22 14:40:57 +02:00
Myzel394
35cff4b6eb
fix(ui): Improve colors 2023-10-22 13:50:26 +02:00
Myzel394
d88b03142f
fix(ui): Improve colors 2023-10-22 13:44:54 +02:00
Myzel394
7b2df0ae0d
fix: Add MODIFY_AUDIO_SETTINGS to allow bluetooth sco 2023-10-22 13:28:18 +02:00
Myzel394
689d830c77
fix: Filter microphones that are sources (not sinks) 2023-10-22 13:18:04 +02:00
Myzel394
8fd57aace3
feat: Allow user to select default microphone if selected on disconnected 2023-10-22 00:42:01 +02:00
Myzel394
d559fb45a5
feat: Add message when microphone is disconnected 2023-10-22 00:33:03 +02:00
Myzel394
76e10a1512
refactor: Move microphone related stuff to MicrophoneStatus 2023-10-22 00:23:02 +02:00
Myzel394
38df00898d
feat: Show address for hidden microphones 2023-10-22 00:04:37 +02:00
Myzel394
c15c4b59fa
feat: Allow user to show all hidden microphones 2023-10-21 23:47:04 +02:00
Myzel394
3f72efc8e6
refactor: Small code improvement 2023-10-21 23:37:41 +02:00
Myzel394
07f3c49a88
feat: Return empty list on error for fetchDeviceMicrophones 2023-10-21 23:30:39 +02:00
Myzel394
dc7a5648a5
chore: Remove debugging 2023-10-21 23:14:36 +02:00
Myzel394
e9e83e00d1
fix(i18n): Improve emphasis 2023-10-21 23:01:44 +02:00
Myzel394
c73f2c3189
fix: change RecorderService state onDestroy 2023-10-21 22:56:52 +02:00
Myzel394
a4edfa539f
fix: Filter out microphones better 2023-10-21 22:50:15 +02:00
Myzel394
e4e8ae0158
fix: Bugfixes for recording behavior 2023-10-21 22:14:53 +02:00
Myzel394
14abd1aee0
feat: Add MicrophoneReconnectedDialog functionality 2023-10-21 21:37:03 +02:00
Myzel394
07757f34bb
feat: Add MicrophoneDisconnectedDialog 2023-10-21 20:37:35 +02:00
Myzel394
78453f1c4d
feat: Add microphone connectivity status 2023-10-21 20:37:27 +02:00
Myzel394
825f0eb33f
fix: Properly stop RecorderService onDestroy 2023-10-21 19:37:17 +02:00
Myzel394
5b7ce77ad3
fix: Improve microphone selection 2023-10-21 19:23:53 +02:00
Myzel394
027e41d6b6
fix: Improve microphone selection 2023-10-21 19:23:04 +02:00
Myzel394
69b4207124
fix(ci-cd): Upload all APKs 2023-10-21 19:17:51 +02:00
Myzel394
a515d2b36c
refactor: Improve selectedDevice; Use own rememberState; Reset selectedDevice on stop 2023-10-21 19:15:15 +02:00
Myzel394
57424cc1d3
fix: Fix button jumping 2023-10-21 19:09:04 +02:00
Myzel394
6e26681acf
feat: Improve RecordingStatus layout 2023-10-21 19:05:58 +02:00
Myzel394
df1d7ce8ff
feat: Add microphone selection 2023-10-21 18:16:08 +02:00
Myzel394
862de21436
feat: Add PoC for sources for AudioRecorderService 2023-10-21 17:18:57 +02:00
Myzel394
46dfee9467
Merge pull request #21 from cem256/master
Added Turkish translations
2023-10-20 21:28:49 +02:00
cem256
94a85acb56 Added Turkish translations 2023-10-20 21:53:18 +03:00
Myzel394
fc643040c6
chore: Update AGP 2023-10-19 19:06:10 +02:00
Myzel394
da29aa62bd
chore: Update version 2023-10-19 19:03:53 +02:00
Myzel394
c55c8d8e57
fix: Add FOREGROUND_SERVICE_MICROPHONE permission for SDK 34 2023-10-19 19:03:21 +02:00
250 changed files with 18523 additions and 1780 deletions

View File

@ -19,16 +19,13 @@ runs:
using: composite
steps:
- name: Write Keystore file 🗄️
id: android_keystore
uses: timheuer/base64-to-file@v1.0.3
with:
fileName: key.jks
encodedString: ${{ inputs.keyStoreBase64 }}
shell: bash
run: echo "${{ inputs.keyStoreBase64 }}" | base64 -d > /home/runner/key.jks
- name: Write Keystore properties 🗝️
shell: bash
run: |
echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties
echo "storeFile=/home/runner/key.jks" > key.properties
echo "storePassword=${{ inputs.signingStorePassword }}" >> key.properties
echo "keyPassword=${{ inputs.signingKeyPassword }}" >> key.properties
echo "keyAlias=${{ inputs.signingKeyAlias }}" >> key.properties

View File

@ -7,15 +7,15 @@ jobs:
debug-builds:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v1
- uses: gradle/wrapper-validation-action@v2
- name: Set up JDK
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: "adopt"
java-version: 19
java-version: 21
cache: "gradle"
- name: Compile
@ -23,6 +23,7 @@ jobs:
./gradlew assembleDebug
- name: Upload APK
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
path: app/build/outputs/apk/debug/app-debug.apk
name: alibi-app-debug-apks
path: app/build/outputs/apk/debug/app-*-debug.apk

View File

@ -10,7 +10,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
- name: Write KeyStore 🗝️
uses: ./.github/actions/prepare-keystore
@ -21,10 +23,10 @@ jobs:
keyStoreBase64: ${{ secrets.KEYSTORE }}
- name: Setup Java
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'adopt'
java-version: "17.x"
java-version: 21
cache: 'gradle'
- name: Build APKs 📱
@ -37,3 +39,14 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
with:
files: app/build/outputs/apk/release/*.apk
- name: Build AABs 📱
run: ./gradlew bundleRelease
- name: Upload APKs bundles 🚀
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
with:
files: app/build/outputs/bundle/release/*.aab

View File

@ -1,4 +1,4 @@
name: Build and publish app
name: Build and publish app to Google Play
on:
release:
@ -10,7 +10,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
- name: Write KeyStore 🗝️
uses: ./.github/actions/prepare-keystore
@ -21,10 +23,10 @@ jobs:
keyStoreBase64: ${{ secrets.KEYSTORE }}
- name: Setup Java
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'adopt'
java-version: "17.x"
java-version: 21
cache: 'gradle'
- name: Build APKs 📱
@ -39,4 +41,4 @@ jobs:
track: production
status: inProgress
inAppUpdatePriority: 2
userFraction: 0.33
userFraction: 0.2

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
*.iml
.gradle
/local.properties
/.idea
/.idea/caches
/.idea/libraries
/.idea/modules.xml

View File

@ -3,18 +3,21 @@
# Alibi
<p float="left" align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.png" width="24%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.png" width="24%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.png" width="24%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.png" width="24%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/01.webp" width="30%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/02.webp" width="30%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/03.webp" width="30%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/04.webp" width="30%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/05.webp" width="30%" />
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/06.webp" width="30%" />
</p>
Alibi keeps recording in the background and saves the last 30 minutes at your request.
Alibi keeps recording audio/video in the background and saves the last 30 minutes at your request.
Everything is completely configurable. No internet connection required.
# Download
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/app.myzel394.alibi)
[<img src="readme_content/google-play-badge.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=app.myzel394.alibi)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/app.myzel394.alibi)
[<img src="readme_content/github-badge.webp" alt="Get it on GitHub" height="80">](https://github.com/Myzel394/Alibi/releases)
# Supporting Alibi
@ -27,16 +30,13 @@ Add a new feature or fix bugs.
## Add translations
Translate Alibi into your language so that other people can use it more easily.
[Translate Alibi into your language using Crowdin](https://crowdin.com/project/alibi), so that other
people can use it more easily.
## Donate
It might sound crazy, but if you would just donate 1$, it would totally mean to world to me, since
it's a really small amount and if everyone did that, I can totally focus on Alibi and my other open
It might sound crazy, but if you would just donate $ 1, it would totally mean the world to me, since
it's a really small amount and if everyone did that, I could focus on Alibi and my other open
source projects. :)
You can donate via:
* [GitHub Sponsors](https://github.com/sponsors/Myzel394)
* Bitcoin: `bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6`
* Monero: `83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8`
You can donate via [GitHub Sponsors](https://github.com/sponsors/Myzel394) or via [crypto currencies](https://github.com/Myzel394/contact-me?tab=readme-ov-file#donations).

View File

@ -35,8 +35,8 @@ android {
applicationId "app.myzel394.alibi"
minSdk 24
targetSdk 34
versionCode 5
versionName "0.2.2"
versionCode 16
versionName "0.5.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -78,9 +78,11 @@ android {
}
buildFeatures {
compose true
buildConfig = true
viewBinding = true
}
composeOptions {
kotlinCompilerExtensionVersion '1.5.1'
kotlinCompilerExtensionVersion '1.5.10'
}
packagingOptions {
resources {
@ -90,41 +92,60 @@ android {
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.core:core-ktx:1.13.1'
implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation platform('androidx.compose:compose-bom:2022.10.00')
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation 'androidx.activity:activity-compose:1.9.1'
implementation 'androidx.activity:activity-ktx:1.9.1'
implementation platform('androidx.compose:compose-bom:2024.09.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation "androidx.compose.material:material-icons-extended:1.5.1"
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.compose.material3:material3:1.2.1'
implementation "androidx.compose.material:material-icons-extended:1.6.8"
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.lifecycle:lifecycle-service:2.8.4'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
androidTestImplementation platform('androidx.compose:compose-bom:2024.09.00')
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
implementation "androidx.navigation:navigation-compose:2.7.2"
implementation "androidx.navigation:navigation-compose:2.7.7"
implementation 'com.google.dagger:hilt-android:2.46.1'
annotationProcessor 'com.google.dagger:hilt-compiler:2.46.1'
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
implementation 'com.google.dagger:hilt-android:2.49'
annotationProcessor 'com.google.dagger:hilt-compiler:2.49'
implementation "androidx.hilt:hilt-navigation-compose:1.2.0"
implementation 'com.arthenica:ffmpeg-kit-min-gpl:5.1'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.1'
implementation "androidx.datastore:datastore-preferences:1.0.0"
implementation 'com.arthenica:ffmpeg-kit-full-gpl:5.1'
implementation "androidx.datastore:datastore-preferences:1.1.1"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
implementation 'com.maxkeppeler.sheets-compose-dialogs:core:1.2.0'
implementation 'com.maxkeppeler.sheets-compose-dialogs:duration:1.2.0'
implementation 'com.maxkeppeler.sheets-compose-dialogs:list:1.2.0'
implementation 'com.maxkeppeler.sheets-compose-dialogs:input:1.2.0'
def camerax_version = "1.3.4"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-video:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
implementation "androidx.camera:camera-extensions:${camerax_version}"
implementation "com.valentinilk.shimmer:compose-shimmer:1.2.0"
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
}

View File

@ -2,10 +2,39 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Required for Bluetooth microphones -->
<uses-permission
android:name="android.permission.MODIFY_AUDIO_SETTINGS"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAPTURE_AUDIO_OUTPUT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- Starting with Android 29, apps don't need to request the READ_EXTERNAL_STORAGE permission
for files in their own MediaStore -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application
android:name=".UpdateSettingsApp"
@ -17,6 +46,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Alibi"
android:hardwareAccelerated="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
@ -37,7 +67,13 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</receiver>
<service android:name=".services.AudioRecorderService" android:foregroundServiceType="microphone" />
<service
android:name=".services.AudioRecorderService"
android:foregroundServiceType="microphone" />
<service
android:name=".services.VideoRecorderService"
android:foregroundServiceType="camera|microphone" />
<!-- Change locale for Android <= 12 -->
<service

View File

@ -1,3 +1,3 @@
package app.myzel394.alibi
val SUPPORTED_LOCALES = arrayOf("en-US", "zh-CN", "de-DE")
val SUPPORTED_LOCALES = arrayOf("en-US", "zh-CN", "de-DE", "tr-TR")

View File

@ -2,13 +2,18 @@ package app.myzel394.alibi
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import androidx.datastore.dataStore
import app.myzel394.alibi.db.AppSettingsSerializer
import app.myzel394.alibi.ui.AsLockedApp
import app.myzel394.alibi.ui.LockedAppHandlers
import app.myzel394.alibi.ui.Navigation
import app.myzel394.alibi.ui.theme.AlibiTheme
@ -26,8 +31,20 @@ class MainActivity : AppCompatActivity() {
setContent {
AlibiTheme {
LockedAppHandlers()
Box(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.background
)
) {
AsLockedApp {
Navigation()
}
}
}
}
}
}

View File

@ -1,21 +1,45 @@
package app.myzel394.alibi.db
import android.Manifest
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.delay
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import app.myzel394.alibi.R
import app.myzel394.alibi.helpers.AudioBatchesFolder
import app.myzel394.alibi.helpers.VideoBatchesFolder
import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
import app.myzel394.alibi.ui.components.RecorderScreen.organisms.RecorderModel
import app.myzel394.alibi.ui.utils.PermissionHelper
import kotlinx.serialization.Serializable
import java.io.File
import kotlinx.serialization.json.Json
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter.ISO_DATE_TIME
@Serializable
data class AppSettings(
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings(),
val audioRecorderSettings: AudioRecorderSettings = AudioRecorderSettings.getDefaultInstance(),
val videoRecorderSettings: VideoRecorderSettings = VideoRecorderSettings.getDefaultInstance(),
val appLockSettings: AppLockSettings? = null,
val hasSeenOnboarding: Boolean = false,
val showAdvancedSettings: Boolean = false,
val theme: Theme = Theme.SYSTEM,
val lastRecording: RecordingInformation? = null,
val filenameFormat: FilenameFormat = FilenameFormat.DATETIME_RELATIVE_START,
/// Recording information
// 30 minutes
val maxDuration: Long = 15 * 60 * 1000L,
// 60 seconds
val intervalDuration: Long = 60 * 1000L,
val notificationSettings: NotificationSettings? = null,
val deleteRecordingsImmediately: Boolean = false,
val saveFolder: String? = null,
) {
fun setShowAdvancedSettings(showAdvancedSettings: Boolean): AppSettings {
return copy(showAdvancedSettings = showAdvancedSettings)
@ -25,123 +49,166 @@ data class AppSettings(
return copy(audioRecorderSettings = audioRecorderSettings)
}
fun setVideoRecorderSettings(videoRecorderSettings: VideoRecorderSettings): AppSettings {
return copy(videoRecorderSettings = videoRecorderSettings)
}
fun setNotificationSettings(notificationSettings: NotificationSettings?): AppSettings {
return copy(notificationSettings = notificationSettings)
}
fun setHasSeenOnboarding(hasSeenOnboarding: Boolean): AppSettings {
return copy(hasSeenOnboarding = hasSeenOnboarding)
}
fun setTheme(theme: Theme): AppSettings {
return copy(theme = theme)
}
fun setLastRecording(lastRecording: RecordingInformation?): AppSettings {
return copy(lastRecording = lastRecording)
}
fun setFilenameFormat(filenameFormat: FilenameFormat): AppSettings {
return copy(filenameFormat = filenameFormat)
}
fun setMaxDuration(duration: Long): AppSettings {
if (duration < 60 * 1000L || duration > 10 * 24 * 60 * 60 * 1000L) {
throw Exception("Max duration must be between 1 minute and 10 days")
}
if (duration < intervalDuration) {
throw Exception("Max duration must be greater than interval duration")
}
return copy(maxDuration = duration)
}
fun setIntervalDuration(duration: Long): AppSettings {
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
throw Exception("Interval duration must be between 10 seconds and 1 hour")
}
if (duration > maxDuration) {
throw Exception("Interval duration must be less than max duration")
}
return copy(intervalDuration = duration)
}
fun setDeleteRecordingsImmediately(deleteRecordingsImmediately: Boolean): AppSettings {
return copy(deleteRecordingsImmediately = deleteRecordingsImmediately)
}
fun setSaveFolder(saveFolder: String?): AppSettings {
return copy(saveFolder = saveFolder)
}
fun setAppLockSettings(appLockSettings: AppLockSettings?): AppSettings {
return copy(appLockSettings = appLockSettings)
}
fun saveLastRecording(recorder: RecorderModel): AppSettings {
return if (deleteRecordingsImmediately) {
this
} else {
setLastRecording(
recorder.recorderService!!.getRecordingInformation()
)
}
}
// If the object is present, biometric authentication is enabled.
// To disable biometric authentication, set the instance to null.
fun isAppLockEnabled() = appLockSettings != null
fun requiresExternalStoragePermission(context: Context): Boolean {
return !SUPPORTS_SCOPED_STORAGE && (saveFolder == RECORDER_MEDIA_SELECTED_VALUE && !PermissionHelper.hasGranted(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE
))
}
fun exportToString(): String {
return Json.encodeToString(serializer(), this)
}
enum class Theme {
SYSTEM,
LIGHT,
DARK,
}
enum class FilenameFormat {
DATETIME_ABSOLUTE_START,
DATETIME_RELATIVE_START,
DATETIME_NOW,
}
companion object {
fun getDefaultInstance(): AppSettings = AppSettings()
fun fromExportedString(data: String): AppSettings {
return Json.decodeFromString(
serializer(),
data,
)
}
}
}
@Serializable
data class LastRecording(
data class RecordingInformation(
val folderPath: String,
@Serializable(with = LocalDateTimeSerializer::class)
val recordingStart: LocalDateTime,
val batchesAmount: Int,
val maxDuration: Long,
val intervalDuration: Long,
val fileExtension: String,
val forceExactMaxDuration: Boolean,
val type: Type,
) {
val fileFolder: File
get() = File(folderPath)
fun hasRecordingsAvailable(context: Context): Boolean =
when (type) {
Type.AUDIO -> AudioBatchesFolder.importFromFolder(folderPath, context)
.hasRecordingsAvailable()
val filePaths: List<File>
get() =
File(folderPath).listFiles()?.filter {
val name = it.nameWithoutExtension
Type.VIDEO -> VideoBatchesFolder.importFromFolder(folderPath, context)
.hasRecordingsAvailable()
}
name.toIntOrNull() != null
}?.toList() ?: emptyList()
val hasRecordingAvailable: Boolean
get() = filePaths.isNotEmpty()
private fun stripConcatenatedFileToExactDuration(
outputFile: File
) {
// Move the concatenated file to a temporary file
val rawFile = File("$folderPath/${outputFile.nameWithoutExtension}-raw.${fileExtension}")
outputFile.renameTo(rawFile)
val command = "-sseof ${maxDuration / -1000} -i $rawFile -y $outputFile"
val session = FFmpegKit.execute(command)
if (!ReturnCode.isSuccess(session.returnCode)) {
Log.d(
"Audio Concatenation",
String.format(
"Command failed with state %s and rc %s.%s",
session.getState(),
session.getReturnCode(),
session.getFailStackTrace()
)
fun getStartDateForFilename(filenameFormat: AppSettings.FilenameFormat): LocalDateTime {
return when (filenameFormat) {
AppSettings.FilenameFormat.DATETIME_ABSOLUTE_START -> recordingStart
AppSettings.FilenameFormat.DATETIME_RELATIVE_START -> LocalDateTime.now().minusSeconds(
getFullDuration() / 1000
)
throw Exception("Failed to strip concatenated audio")
AppSettings.FilenameFormat.DATETIME_NOW -> LocalDateTime.now()
}
}
suspend fun concatenateFiles(forceConcatenation: Boolean = false): File {
val paths = filePaths.joinToString("|")
val fileName = recordingStart
.format(ISO_DATE_TIME)
.toString()
.replace(":", "-")
.replace(".", "_")
val outputFile = File("$fileFolder/$fileName.${fileExtension}")
if (outputFile.exists() && !forceConcatenation) {
return outputFile
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()
}
val command = "-i 'concat:$paths' -y" +
" -acodec copy" +
" -metadata title='$fileName' " +
" -metadata date='${recordingStart.format(ISO_DATE_TIME)}'" +
" -metadata batch_count='${filePaths.size}'" +
" -metadata batch_duration='${intervalDuration}'" +
" -metadata max_duration='${maxDuration}'" +
" $outputFile"
val session = FFmpegKit.execute(command)
if (!ReturnCode.isSuccess(session.returnCode)) {
Log.d(
"Audio Concatenation",
String.format(
"Command failed with state %s and rc %s.%s",
session.getState(),
session.getReturnCode(),
session.getFailStackTrace()
)
)
throw Exception("Failed to concatenate audios")
}
val minRequiredForPossibleInExactMaxDuration = maxDuration / intervalDuration
if (forceExactMaxDuration && filePaths.size > minRequiredForPossibleInExactMaxDuration) {
stripConcatenatedFileToExactDuration(outputFile)
}
return outputFile
enum class Type {
AUDIO,
VIDEO,
}
}
@Serializable
data class AudioRecorderSettings(
val maxDuration: Long = 30 * 60 * 1000L,
// 60 seconds
val intervalDuration: Long = 60 * 1000L,
val forceExactMaxDuration: Boolean = true,
// 320 Kbps
val bitRate: Int = 320000,
val samplingRate: Int? = null,
val outputFormat: Int? = null,
val encoder: Int? = null,
val showAllMicrophones: Boolean = false,
) {
fun getOutputFormat(): Int {
if (outputFormat != null) {
@ -167,6 +234,7 @@ data class AudioRecorderSettings(
MediaRecorder.OutputFormat.AAC_ADTS
}
}
MediaRecorder.AudioEncoder.OPUS -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaRecorder.OutputFormat.OGG
@ -174,6 +242,7 @@ data class AudioRecorderSettings(
MediaRecorder.OutputFormat.AAC_ADTS
}
}
else -> MediaRecorder.OutputFormat.DEFAULT
}
}
@ -202,26 +271,12 @@ data class AudioRecorderSettings(
else -> 48000
}
fun getEncoder(): Int = encoder ?:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
fun getEncoder(): Int = encoder ?: if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
MediaRecorder.AudioEncoder.AAC
else
MediaRecorder.AudioEncoder.AMR_NB
fun setIntervalDuration(duration: Long): AudioRecorderSettings {
if (duration < 10 * 1000L || duration > 60 * 60 * 1000L) {
throw Exception("Interval duration must be between 10 seconds and 1 hour")
}
if (duration > maxDuration) {
throw Exception("Interval duration must be less than max duration")
}
return copy(intervalDuration = duration)
}
fun setBitRate(bitRate: Int): AudioRecorderSettings {
println("bitRate: $bitRate")
if (bitRate !in 1000..320000) {
throw Exception("Bit rate must be between 1000 and 320000")
}
@ -253,20 +308,8 @@ data class AudioRecorderSettings(
return copy(encoder = encoder)
}
fun setMaxDuration(duration: Long): AudioRecorderSettings {
if (duration < 60 * 1000L || duration > 24 * 60 * 60 * 1000L) {
throw Exception("Max duration must be between 1 minute and 1 hour")
}
if (duration < intervalDuration) {
throw Exception("Max duration must be greater than interval duration")
}
return copy(maxDuration = duration)
}
fun setForceExactMaxDuration(forceExactMaxDuration: Boolean): AudioRecorderSettings {
return copy(forceExactMaxDuration = forceExactMaxDuration)
fun setShowAllMicrophones(showAllMicrophones: Boolean): AudioRecorderSettings {
return copy(showAllMicrophones = showAllMicrophones)
}
fun isEncoderCompatible(encoder: Int): Boolean {
@ -279,17 +322,31 @@ data class AudioRecorderSettings(
return supportedFormats.contains(outputFormat)
}
val fileExtension: String
get() = when (getOutputFormat()) {
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
MediaRecorder.OutputFormat.WEBM -> "webm"
MediaRecorder.OutputFormat.AMR_NB -> "amr"
MediaRecorder.OutputFormat.AMR_WB -> "awb"
MediaRecorder.OutputFormat.OGG -> "ogg"
else -> "raw"
}
companion object {
fun getDefaultInstance(): AudioRecorderSettings = AudioRecorderSettings()
val EXAMPLE_MAX_DURATIONS = listOf(
1 * 60 * 1000L,
5 * 60 * 1000L,
15 * 60 * 1000L,
30 * 60 * 1000L,
60 * 60 * 1000L,
2 * 60 * 60 * 1000L,
3 * 60 * 60 * 1000L,
)
val EXAMPLE_DURATION_TIMES = listOf(
60 * 1000L,
60 * 2 * 1000L,
60 * 5 * 1000L,
60 * 10 * 1000L,
60 * 15 * 1000L,
@ -387,3 +444,174 @@ data class AudioRecorderSettings(
}).toMap()
}
}
@Serializable
data class VideoRecorderSettings(
val targetedVideoBitRate: Int? = null,
val quality: String? = null,
val targetFrameRate: Int? = null,
) {
fun setTargetedVideoBitRate(bitRate: Int?): VideoRecorderSettings {
return copy(targetedVideoBitRate = bitRate)
}
fun setQuality(quality: Quality?): VideoRecorderSettings {
val invertedMap = QUALITY_NAME_QUALITY_MAP.entries.associateBy({ it.value }, { it.key })
return copy(quality = quality?.let { invertedMap[it] })
}
fun setTargetFrameRate(frameRate: Int?): VideoRecorderSettings {
return copy(targetFrameRate = frameRate)
}
fun getQuality(): Quality? =
quality?.let {
QUALITY_NAME_QUALITY_MAP[it]!!
}
fun getQualitySelector(): QualitySelector? =
quality?.let {
QualitySelector.from(
QUALITY_NAME_QUALITY_MAP[it]!!
)
}
fun getMimeType() = "video/$fileExtension"
val fileExtension
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) "mp4" else "3gp"
companion object {
fun getDefaultInstance() = VideoRecorderSettings()
val QUALITY_NAME_QUALITY_MAP: Map<String, Quality> = mapOf(
"LOWEST" to Quality.LOWEST,
"HIGHEST" to Quality.HIGHEST,
"SD" to Quality.SD,
"HD" to Quality.HD,
"FHD" to Quality.FHD,
"UHD" to Quality.UHD,
)
val EXAMPLE_BITRATE_VALUES = listOf(
null,
500 * 1000,
// 1 Mbps
1 * 1000 * 1000,
2 * 1000 * 1000,
4 * 1000 * 1000,
8 * 1000 * 1000,
16 * 1000 * 1000,
32 * 1000 * 1000,
50 * 1000 * 1000,
100 * 1000 * 1000,
)
val EXAMPLE_FRAME_RATE_VALUES = listOf(
null,
24,
30,
60,
120,
240,
)
val AVAILABLE_QUALITIES = listOf(
Quality.HIGHEST,
Quality.UHD,
Quality.FHD,
Quality.HD,
Quality.SD,
Quality.LOWEST,
)
val EXAMPLE_QUALITY_VALUES = listOf(
null,
) + AVAILABLE_QUALITIES
}
}
@Serializable
data class NotificationSettings(
val title: String,
val message: String,
val iconID: Int,
val showOngoing: Boolean,
val preset: Preset? = null,
) {
@Serializable
sealed class Preset(
val titleID: Int,
val messageID: Int,
val showOngoing: Boolean,
val iconID: Int,
) {
@Serializable
data object Default : Preset(
R.string.ui_audioRecorder_state_recording_title,
R.string.ui_recorder_state_recording_description,
true,
R.drawable.launcher_monochrome_noopacity,
)
@Serializable
data object Weather : Preset(
R.string.ui_recorder_state_recording_fake_weather_title,
R.string.ui_recorder_state_recording_fake_weather_description,
false,
R.drawable.ic_cloud
)
@Serializable
data object Player : Preset(
R.string.ui_recorder_state_recording_fake_player_title,
R.string.ui_recorder_state_recording_fake_player_description,
true,
R.drawable.ic_note,
)
@Serializable
data object Browser : Preset(
R.string.ui_recorder_state_recording_fake_browser_title,
R.string.ui_recorder_state_recording_fake_browser_description,
true,
R.drawable.ic_download,
)
@Serializable
data object VPN : Preset(
R.string.ui_recorder_state_recording_fake_vpn_title,
R.string.ui_recorder_state_recording_fake_vpn_description,
false,
R.drawable.ic_vpn,
)
}
companion object {
fun fromPreset(preset: Preset): NotificationSettings {
return NotificationSettings(
title = "",
message = "",
showOngoing = preset.showOngoing,
iconID = preset.iconID,
preset = preset,
)
}
val PRESETS = listOf(
Preset.Default,
Preset.Weather,
Preset.Player,
Preset.Browser,
Preset.VPN,
)
}
}
@Serializable
class AppLockSettings {
companion object {
fun getDefaultInstance() = AppLockSettings()
}
}

View File

@ -40,7 +40,8 @@ class AppSettingsSerializer: Serializer<AppSettings> {
}
class LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): LocalDateTime {
return LocalDateTime.parse(decoder.decodeString())

View File

@ -1,7 +1,10 @@
package app.myzel394.alibi.enums
enum class RecorderState {
IDLE,
STOPPED,
RECORDING,
PAUSED,
// Only used by the model to indicate that the service is not running
IDLE
}

View File

@ -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)
}
}
}

View File

@ -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",
)
}
}

View 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
}
*/
}
}
}

View 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
}
}

View 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)
}

View File

@ -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",
)
}
}

View File

@ -1,46 +1,43 @@
package app.myzel394.alibi.services
import android.content.Context
import android.content.pm.ServiceInfo
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.media.MediaRecorder
import android.media.MediaRecorder.OnErrorListener
import android.os.Build
import java.lang.IllegalStateException
import android.os.Handler
import android.os.Looper
import androidx.core.app.ServiceCompat
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.db.RecordingInformation
import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.helpers.AudioBatchesFolder
import app.myzel394.alibi.helpers.BatchesFolder
import app.myzel394.alibi.ui.utils.MicrophoneInfo
class AudioRecorderService: IntervalRecorderService() {
class AudioRecorderService :
IntervalRecorderService<RecordingInformation, AudioBatchesFolder>() {
override var batchesFolder = AudioBatchesFolder.viaInternalFolder(this)
private val handler = Handler(Looper.getMainLooper())
var amplitudes = mutableListOf<Int>()
private set
var amplitudesAmount = 1000
var selectedMicrophone: MicrophoneInfo? = null
var recorder: MediaRecorder? = null
private set
var onError: () -> Unit = {}
val filePath: String
get() = "$folder/$counter.${settings!!.fileExtension}"
private fun createRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
MediaRecorder()
}.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFile(filePath)
setOutputFormat(settings!!.outputFormat)
setAudioEncoder(settings!!.encoder)
setAudioEncodingBitRate(settings!!.bitRate)
setAudioSamplingRate(settings!!.samplingRate)
setOnErrorListener(OnErrorListener { _, _, _ ->
onError()
})
}
}
private fun resetRecorder() {
runCatching {
recorder?.let {
it.stop()
it.release()
}
}
}
// Callbacks
var onSelectedMicrophoneChange: (MicrophoneInfo?) -> Unit = {}
var onMicrophoneDisconnected: () -> Unit = {}
var onMicrophoneReconnected: () -> Unit = {}
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
override fun startNewCycle() {
super.startNewCycle()
@ -50,6 +47,7 @@ class AudioRecorderService: IntervalRecorderService() {
}
resetRecorder()
startAudioDevice()
try {
recorder = newRecorder
@ -59,21 +57,48 @@ class AudioRecorderService: IntervalRecorderService() {
}
}
override fun start() {
super.start()
createAmplitudesTimer()
registerMicrophoneListener()
}
override fun pause() {
super.pause()
resetRecorder()
}
override fun stop() {
super.stop()
override suspend fun stop() {
resetRecorder()
unregisterMicrophoneListener()
super.stop()
}
override fun getAmplitudeAmount(): Int = amplitudesAmount
override fun resume() {
super.resume()
createAmplitudesTimer()
}
override fun getAmplitude(): Int {
override fun startForegroundService() {
ServiceCompat.startForeground(
this,
NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID,
getNotificationHelper().buildStartingNotification(),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
0
},
)
}
// ==== Amplitude related ====
private fun getAmplitudeAmount(): Int = amplitudesAmount
private fun getAmplitude(): Int {
return try {
recorder!!.maxAmplitude
} catch (error: IllegalStateException) {
@ -82,4 +107,209 @@ class AudioRecorderService: IntervalRecorderService() {
0
}
}
private fun updateAmplitude() {
if (state !== RecorderState.RECORDING) {
return
}
amplitudes.add(getAmplitude())
onAmplitudeChange?.invoke(amplitudes)
// Delete old amplitudes
if (amplitudes.size > getAmplitudeAmount()) {
// Should be more efficient than dropping the elements, getting a new list
// clearing old list and adding new elements to it
repeat(amplitudes.size - getAmplitudeAmount()) {
amplitudes.removeAt(0)
}
}
handler.postDelayed(::updateAmplitude, 100)
}
private fun createAmplitudesTimer() {
handler.postDelayed(::updateAmplitude, 100)
}
// ==== Audio device related ====
/// Tell Android to use the correct bluetooth microphone, if any selected
private fun startAudioDevice() {
if (selectedMicrophone == null) {
return
}
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManger.setCommunicationDevice(selectedMicrophone!!.deviceInfo)
} else {
audioManger.startBluetoothSco()
}
}
private fun clearAudioDevice() {
val audioManger = getSystemService(AUDIO_SERVICE)!! as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManger.clearCommunicationDevice()
} else {
audioManger.stopBluetoothSco()
}
}
private fun getNameForMediaFile() =
"${batchesFolder.mediaPrefix}$counter.${settings.audioRecorderSettings.fileExtension}"
// ==== Actual recording related ====
private fun createRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
MediaRecorder()
}.apply {
val audioSettings = settings.audioRecorderSettings
// Audio Source is kinda strange, here are my experimental findings using a Pixel 7 Pro
// and Redmi Buds 3 Pro:
// - MIC: Uses the bottom microphone of the phone (17)
// - CAMCORDER: Uses the top microphone of the phone (2)
// - VOICE_COMMUNICATION: Uses the bottom microphone of the phone (17)
// - DEFAULT: Uses the bottom microphone of the phone (17)
setAudioSource(MediaRecorder.AudioSource.MIC)
when (batchesFolder.type) {
BatchesFolder.BatchType.INTERNAL -> {
setOutputFile(
batchesFolder.asInternalGetFile(
counter,
audioSettings.fileExtension
).absolutePath
)
}
BatchesFolder.BatchType.CUSTOM -> {
setOutputFile(
batchesFolder.asCustomGetFileDescriptor(
counter,
audioSettings.fileExtension
)
)
}
BatchesFolder.BatchType.MEDIA -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setOutputFile(
batchesFolder.asMediaGetScopedStorageFileDescriptor(
getNameForMediaFile(),
"audio/${audioSettings.fileExtension}"
)
)
} else {
val name = getNameForMediaFile()
val file = batchesFolder.asMediaGetLegacyFile(name)
setOutputFile(file.absolutePath)
}
}
}
setOutputFormat(audioSettings.getOutputFormat())
setAudioEncoder(audioSettings.getEncoder())
setAudioEncodingBitRate(audioSettings.bitRate)
setAudioSamplingRate(audioSettings.getSamplingRate())
setOnErrorListener(OnErrorListener { _, _, _ ->
onError()
})
}
}
// ==== Microphone related ====
private fun resetRecorder() {
runCatching {
recorder?.apply {
stop()
reset()
release()
}
clearAudioDevice()
batchesFolder.cleanup()
}
}
fun changeMicrophone(microphone: MicrophoneInfo?) {
selectedMicrophone = microphone
onSelectedMicrophoneChange(microphone)
if (state == RecorderState.RECORDING) {
startNewCycle()
}
}
private val audioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
super.onAudioDevicesAdded(addedDevices)
if (selectedMicrophone == null) {
return
}
// We can't compare the ID, as it seems to be changing on each reconnect
val newDevice = addedDevices?.find {
it.productName == selectedMicrophone!!.deviceInfo.productName &&
it.isSink == selectedMicrophone!!.deviceInfo.isSink &&
it.type == selectedMicrophone!!.deviceInfo.type && (
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
it.address == selectedMicrophone!!.deviceInfo.address
} else true
)
}
if (newDevice != null) {
changeMicrophone(MicrophoneInfo.fromDeviceInfo(newDevice))
onMicrophoneReconnected()
}
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
super.onAudioDevicesRemoved(removedDevices)
if (selectedMicrophone == null) {
return
}
if (removedDevices?.find { it.id == selectedMicrophone!!.deviceInfo.id } != null) {
onMicrophoneDisconnected()
}
}
}
private fun registerMicrophoneListener() {
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
audioManager.registerAudioDeviceCallback(
audioDeviceCallback,
Handler(Looper.getMainLooper())
)
}
private fun unregisterMicrophoneListener() {
val audioManager = getSystemService(Context.AUDIO_SERVICE)!! as AudioManager
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
}
// ==== Settings ====
override fun getRecordingInformation() =
RecordingInformation(
folderPath = batchesFolder.exportFolderForSettings(),
recordingStart = recordingStart,
maxDuration = settings.maxDuration,
batchesAmount = batchesFolder.getBatchesForFFmpeg().size,
fileExtension = settings.audioRecorderSettings.fileExtension,
intervalDuration = settings.intervalDuration,
type = RecordingInformation.Type.AUDIO,
)
}

View File

@ -1,51 +0,0 @@
package app.myzel394.alibi.services
import android.os.Handler
import android.os.Looper
import app.myzel394.alibi.enums.RecorderState
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
abstract class ExtraRecorderInformationService: RecorderService() {
abstract fun getAmplitudeAmount(): Int
abstract fun getAmplitude(): Int
var amplitudes = mutableListOf<Int>()
private set
private val handler = Handler(Looper.getMainLooper())
var onAmplitudeChange: ((List<Int>) -> Unit)? = null
private fun updateAmplitude() {
if (state !== RecorderState.RECORDING) {
return
}
amplitudes.add(getAmplitude())
onAmplitudeChange?.invoke(amplitudes)
// Delete old amplitudes
if (amplitudes.size > getAmplitudeAmount()) {
amplitudes.drop(amplitudes.size - getAmplitudeAmount())
}
handler.postDelayed(::updateAmplitude, 100)
}
private fun createAmplitudesTimer() {
handler.postDelayed(::updateAmplitude, 100)
}
override fun start() {
createAmplitudesTimer()
}
override fun resume() {
createAmplitudesTimer()
}
}

View File

@ -1,45 +1,45 @@
package app.myzel394.alibi.services
import android.content.Context
import android.media.MediaRecorder
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AudioRecorderSettings
import app.myzel394.alibi.db.LastRecording
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.File
import java.time.LocalDateTime
import java.util.Timer
import java.util.TimerTask
import java.util.UUID
import java.util.concurrent.Executor
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.helpers.BatchesFolder
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
abstract class IntervalRecorderService: ExtraRecorderInformationService() {
private var job = SupervisorJob()
private var scope = CoroutineScope(Dispatchers.IO + job)
protected var counter = 0
abstract class IntervalRecorderService<I, B : BatchesFolder> :
RecorderService() {
protected var counter = 0L
private set
protected lateinit var folder: File
var settings: Settings? = null
protected set
// Tracks the index of the currently locked file
private var lockedIndex: Long? = null
lateinit var settings: AppSettings
private lateinit var cycleTimer: ScheduledExecutorService
fun createLastRecording(): LastRecording = LastRecording(
folderPath = folder.absolutePath,
recordingStart = recordingStart,
maxDuration = settings!!.maxDuration,
fileExtension = settings!!.fileExtension,
intervalDuration = settings!!.intervalDuration,
forceExactMaxDuration = settings!!.forceExactMaxDuration,
)
abstract var batchesFolder: B
var onBatchesFolderNotAccessible: () -> Unit = {}
abstract fun getRecordingInformation(): I
// When saving the recording, the files should be locked.
// This prevents the service from deleting the currently available files, so that
// they can be safely used to save the recording.
// Once finished, make sure to unlock the files using `unlockFiles`.
fun lockFiles() {
lockedIndex = counter
}
// Unlocks and deletes the files that were locked using `lockFiles`.
fun unlockFiles(cleanupFiles: Boolean = false) {
if (cleanupFiles) {
batchesFolder.deleteRecordings(0..<lockedIndex!!)
}
lockedIndex = null
}
// Make overrideable
open fun startNewCycle() {
@ -50,103 +50,56 @@ abstract class IntervalRecorderService: ExtraRecorderInformationService() {
private fun createTimer() {
cycleTimer = Executors.newSingleThreadScheduledExecutor().also {
it.scheduleAtFixedRate(
{
startNewCycle()
},
::startNewCycle,
0,
settings!!.intervalDuration,
settings.intervalDuration,
TimeUnit.MILLISECONDS
)
}
}
private fun getRandomFileFolder(): String {
// uuid
val folder = UUID.randomUUID().toString()
return "${externalCacheDir!!.absolutePath}/$folder"
}
override fun start() {
super.start()
folder = File(getRandomFileFolder())
folder.mkdirs()
batchesFolder.initFolders()
scope.launch {
dataStore.data.collectLatest { preferenceSettings ->
if (settings == null) {
settings = Settings.from(preferenceSettings.audioRecorderSettings)
if (!batchesFolder.checkIfFolderIsAccessible()) {
onBatchesFolderNotAccessible()
throw AvoidErrorDialogError()
}
createTimer()
}
}
}
}
override fun pause() {
super.pause()
cycleTimer.shutdown()
}
override fun resume() {
createTimer()
// We first want to start our timers, so the `ExtraRecorderInformationService` can fetch
// amplitudes
super.resume()
createTimer()
}
override fun stop() {
override suspend fun stop() {
cycleTimer.shutdown()
batchesFolder.cleanup()
super.stop()
}
fun clearAllRecordings() {
batchesFolder.deleteRecordings()
}
private fun deleteOldRecordings() {
val timeMultiplier = settings!!.maxDuration / settings!!.intervalDuration
val earliestCounter = counter - timeMultiplier
val timeMultiplier = settings.maxDuration / settings.intervalDuration
val earliestCounter = Math.max(counter - timeMultiplier, lockedIndex ?: 0)
folder.listFiles()?.forEach { file ->
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
if (fileCounter < earliestCounter) {
file.delete()
}
}
if (earliestCounter <= 0) {
return
}
data class Settings(
val maxDuration: Long,
val intervalDuration: Long,
val forceExactMaxDuration: Boolean,
val bitRate: Int,
val samplingRate: Int,
val outputFormat: Int,
val encoder: Int,
) {
val fileExtension: String
get() = when(outputFormat) {
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
MediaRecorder.OutputFormat.WEBM -> "webm"
MediaRecorder.OutputFormat.AMR_NB -> "amr"
MediaRecorder.OutputFormat.AMR_WB -> "awb"
MediaRecorder.OutputFormat.OGG -> "ogg"
else -> "raw"
}
companion object {
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
return Settings(
intervalDuration = audioRecorderSettings.intervalDuration,
bitRate = audioRecorderSettings.bitRate,
samplingRate = audioRecorderSettings.getSamplingRate(),
outputFormat = audioRecorderSettings.getOutputFormat(),
encoder = audioRecorderSettings.getEncoder(),
maxDuration = audioRecorderSettings.maxDuration,
forceExactMaxDuration = audioRecorderSettings.forceExactMaxDuration,
)
}
}
batchesFolder.deleteRecordings(0..earliestCounter)
}
}

View 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.
![Diagram](model.svg)

View File

@ -0,0 +1,159 @@
package app.myzel394.alibi.services
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import app.myzel394.alibi.MainActivity
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.R
import app.myzel394.alibi.db.NotificationSettings
import app.myzel394.alibi.enums.RecorderState
import kotlinx.serialization.Serializable
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Calendar
import java.util.Date
data class RecorderNotificationHelper(
val context: Context,
val details: NotificationDetails? = null,
) {
@Serializable
data class NotificationDetails(
val title: String,
val description: String,
val icon: Int,
val isOngoing: Boolean,
) {
companion object {
fun fromNotificationSettings(
context: Context,
settings: NotificationSettings,
): NotificationDetails {
return if (settings.preset == null) {
NotificationDetails(
settings.title,
settings.message,
settings.iconID,
settings.showOngoing,
)
} else {
NotificationDetails(
context.getString(settings.preset.titleID),
context.getString(settings.preset.messageID),
settings.preset.iconID,
settings.preset.showOngoing,
)
}
}
}
}
private fun getNotificationChangeStateIntent(
newState: RecorderState,
requestCode: Int
): PendingIntent {
return PendingIntent.getService(
context,
requestCode,
Intent(context, context::class.java).apply {
action = "changeState"
putExtra("newState", newState.name)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun getIconID(): Int = details?.icon ?: R.drawable.launcher_monochrome_noopacity
private fun createBaseNotification(): NotificationCompat.Builder {
return NotificationCompat.Builder(
context,
NotificationHelper.RECORDER_CHANNEL_ID
)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setSmallIcon(getIconID())
.setContentIntent(
PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
)
.setSilent(true)
.setOnlyAlertOnce(true)
.setChronometerCountDown(false)
}
private fun getStringForRecorder(audioRes: Int, videoRes: Int): String =
when (context::class.java) {
AudioRecorderService::class.java -> context.getString(audioRes)
VideoRecorderService::class.java -> context.getString(videoRes)
else -> ""
}
fun buildStartingNotification(): Notification {
return createBaseNotification()
.setContentTitle(
getStringForRecorder(
R.string.ui_audioRecorder_state_recording_title,
R.string.ui_videoRecorder_state_recording_title,
)
)
.setContentText(context.getString(R.string.ui_recorder_state_recording_description))
.build()
}
fun buildRecordingNotification(recordingTime: Long): Notification {
return createBaseNotification()
.setUsesChronometer(details?.isOngoing ?: true)
.setOngoing(details?.isOngoing ?: true)
.setShowWhen(details?.isOngoing ?: true)
.setWhen(
Date.from(
Calendar
.getInstance()
.also { it.add(Calendar.SECOND, -recordingTime.toInt()) }
.toInstant()
).time,
)
.addAction(
R.drawable.ic_pause,
context.getString(R.string.ui_recorder_action_pause_label),
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
)
.setContentTitle(
details?.title
?: getStringForRecorder(
R.string.ui_audioRecorder_state_recording_title,
R.string.ui_videoRecorder_state_recording_title,
)
)
.setContentText(
details?.description
?: context.getString(R.string.ui_recorder_state_recording_description)
)
.build()
}
fun buildPausedNotification(start: LocalDateTime): Notification {
return createBaseNotification()
.setContentTitle(context.getString(R.string.ui_recorder_state_paused_title))
.setContentText(context.getString(R.string.ui_recorder_state_paused_description))
.setOngoing(false)
.setUsesChronometer(false)
.setWhen(Date.from(start.atZone(ZoneId.systemDefault()).toInstant()).time)
.addAction(
R.drawable.ic_play,
context.getString(R.string.ui_recorder_action_resume_label),
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
)
.build()
}
}

View File

@ -2,58 +2,117 @@ package app.myzel394.alibi.services
import android.annotation.SuppressLint
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import app.myzel394.alibi.MainActivity
import androidx.lifecycle.LifecycleService
import app.myzel394.alibi.NotificationHelper
import app.myzel394.alibi.R
import app.myzel394.alibi.enums.RecorderState
import app.myzel394.alibi.ui.utils.PermissionHelper
import kotlinx.serialization.json.Json
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Calendar
import java.util.Date
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
abstract class RecorderService: Service() {
abstract class RecorderService : LifecycleService() {
private val binder = RecorderBinder()
private var isPaused: Boolean = false
lateinit var recordingStart: LocalDateTime
private set
private lateinit var recordingTimeTimer: ScheduledExecutorService
private var notificationDetails: RecorderNotificationHelper.NotificationDetails? = null
var state = RecorderState.IDLE
private set
var onStateChange: ((RecorderState) -> Unit)? = null
var onError: () -> Unit = {}
var onRecordingTimeChange: ((Long) -> Unit)? = null
var recordingTime = 0L
private set
private lateinit var recordingTimeTimer: ScheduledExecutorService
var onRecordingTimeChange: ((Long) -> Unit)? = null
protected abstract fun start()
protected abstract fun pause()
protected abstract fun resume()
protected abstract fun stop()
protected open fun start() {
createRecordingTimeTimer()
}
override fun onBind(p0: Intent?): IBinder? = binder
protected open fun pause() {
isPaused = true
recordingTimeTimer.shutdown()
}
protected open fun resume() {
createRecordingTimeTimer()
}
protected open suspend fun stop() {
recordingTimeTimer.shutdown()
}
protected abstract fun startForegroundService()
fun startRecording() {
recordingStart = LocalDateTime.now()
startForegroundService()
changeState(RecorderState.RECORDING)
try {
start()
} catch (error: RuntimeException) {
error.printStackTrace()
if (error !is AvoidErrorDialogError) {
onError()
}
}
}
suspend fun stopRecording() {
changeState(RecorderState.STOPPED)
stop()
}
fun pauseRecording() {
changeState(RecorderState.PAUSED)
}
fun resumeRecording() {
changeState(RecorderState.RECORDING)
}
fun destroy() {
NotificationManagerCompat.from(this)
.cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
return binder
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
"init" -> {
notificationDetails = intent.getStringExtra("notificationDetails")?.let {
Json.decodeFromString(
RecorderNotificationHelper.NotificationDetails.serializer(),
it
)
}
}
"changeState" -> {
val newState = intent.getStringExtra("newState")?.let {
RecorderState.valueOf(it)
} ?: RecorderState.IDLE
} ?: RecorderState.STOPPED
changeState(newState)
}
}
@ -69,16 +128,19 @@ abstract class RecorderService: Service() {
recordingTimeTimer = Executors.newSingleThreadScheduledExecutor().also {
it.scheduleAtFixedRate(
{
recordingTime += 1000
recordingTime += 1
onRecordingTimeChange?.invoke(recordingTime)
},
0,
1000,
TimeUnit.MILLISECONDS
1,
TimeUnit.SECONDS
)
}
}
// Used to change the state of the service
// will internally call start() / pause() / resume() / stop()
// Immediately after creating the service make sure to call `changeState(RecorderState.RECORDING)`
@SuppressLint("MissingPermission")
fun changeState(newState: RecorderState) {
if (state == newState) {
@ -91,30 +153,16 @@ abstract class RecorderService: Service() {
if (isPaused) {
resume()
isPaused = false
} else {
start()
}
}
RecorderState.PAUSED -> {
pause()
isPaused = true
}
RecorderState.IDLE -> {
stop()
onDestroy()
}
// `start` is handled by `startRecording`
}
when (newState) {
RecorderState.RECORDING -> {
createRecordingTimeTimer()
}
RecorderState.PAUSED, RecorderState.IDLE -> {
recordingTimeTimer.shutdown()
}
RecorderState.PAUSED -> pause()
else -> {}
}
// Update notification
if (
arrayOf(
RecorderState.RECORDING,
@ -128,114 +176,34 @@ abstract class RecorderService: Service() {
notification
)
}
onStateChange?.invoke(newState)
}
// Must be immediately called after creating the service!
fun startRecording() {
recordingStart = LocalDateTime.now()
val notification = buildStartNotification()
startForeground(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID, notification)
// Start
changeState(RecorderState.RECORDING)
protected fun getNotificationHelper(): RecorderNotificationHelper {
return RecorderNotificationHelper(this, notificationDetails)
}
override fun onDestroy() {
super.onDestroy()
private fun buildNotification(): Notification {
val notificationHelper = getNotificationHelper()
changeState(RecorderState.IDLE)
stopForeground(STOP_FOREGROUND_REMOVE)
NotificationManagerCompat.from(this).cancel(NotificationHelper.RECORDER_CHANNEL_NOTIFICATION_ID)
stopSelf()
return when (state) {
RecorderState.RECORDING -> {
notificationHelper.buildRecordingNotification(recordingTime)
}
private fun buildStartNotification(): Notification = NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
.setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
.setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
.setSmallIcon(R.drawable.launcher_foreground)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.build()
private fun getNotificationChangeStateIntent(newState: RecorderState, requestCode: Int): PendingIntent {
return PendingIntent.getService(
this,
requestCode,
Intent(this, AudioRecorderService::class.java).apply {
action = "changeState"
putExtra("newState", newState.name)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
RecorderState.PAUSED -> {
notificationHelper.buildPausedNotification(recordingStart)
}
private fun buildNotification(): Notification = when(state) {
RecorderState.RECORDING -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
.setContentTitle(getString(R.string.ui_audioRecorder_state_recording_title))
.setContentText(getString(R.string.ui_audioRecorder_state_recording_description))
.setSmallIcon(R.drawable.launcher_foreground)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setOngoing(true)
.setWhen(
Date.from(
Calendar
.getInstance()
.also { it.add(Calendar.MILLISECOND, -recordingTime.toInt()) }
.toInstant()
).time,
)
.setSilent(true)
.setOnlyAlertOnce(true)
.setUsesChronometer(true)
.setChronometerCountDown(false)
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
)
.addAction(
R.drawable.ic_cancel,
getString(R.string.ui_audioRecorder_action_delete_label),
getNotificationChangeStateIntent(RecorderState.IDLE, 1),
)
.addAction(
R.drawable.ic_pause,
getString(R.string.ui_audioRecorder_action_pause_label),
getNotificationChangeStateIntent(RecorderState.PAUSED, 2),
)
.build()
RecorderState.PAUSED -> NotificationCompat.Builder(this, NotificationHelper.RECORDER_CHANNEL_ID)
.setContentTitle(getString(R.string.ui_audioRecorder_state_paused_title))
.setContentText(getString(R.string.ui_audioRecorder_state_paused_description))
.setSmallIcon(R.drawable.launcher_foreground)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setOngoing(false)
.setOnlyAlertOnce(true)
.setUsesChronometer(false)
.setWhen(Date.from(recordingStart.atZone(ZoneId.systemDefault()).toInstant()).time)
.setShowWhen(true)
.setContentIntent(
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE,
)
)
.addAction(
R.drawable.ic_play,
getString(R.string.ui_audioRecorder_action_resume_label),
getNotificationChangeStateIntent(RecorderState.RECORDING, 3),
)
.build()
else -> throw IllegalStateException("Invalid state passed to `buildNotification()`")
else -> {
throw IllegalStateException("Notification can't be built in state $state")
}
}
}
// Throw this error if you show a dialog yourself.
// This will prevent the service from showing their generic error dialog.
class AvoidErrorDialogError : RuntimeException()
}

View File

@ -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()
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View 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,
)
}
}
}
}

View File

@ -1,6 +1,80 @@
package app.myzel394.alibi.ui
import android.os.Build
import androidx.compose.ui.unit.dp
import java.util.Base64
val BIG_PRIMARY_BUTTON_SIZE = 64.dp
val BIG_PRIMARY_BUTTON_MAX_WIDTH = 450.dp
val SHEET_BOTTOM_OFFSET = 24.dp
val MAX_AMPLITUDE = 20000
val SUPPORTS_DARK_MODE_NATIVELY = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
val MEDIA_SUBFOLDER_NAME = "alibi"
val SUPPORTS_SCOPED_STORAGE = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
val SUPPORTS_SAVING_VIDEOS_IN_CUSTOM_FOLDERS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
val MEDIA_RECORDINGS_PREFIX = "alibi-recording-"
val RECORDER_MEDIA_SELECTED_VALUE = "_'media"
val RECORDER_INTERNAL_SELECTED_VALUE = "_'internal"
val VIDEO_RECORDING_BATCHES_SUBFOLDER_NAME = ".video_recordings"
val AUDIO_RECORDING_BATCHES_SUBFOLDER_NAME = ".audio_recordings"
// You are not allowed to change the constants below.
// If you do so, you will be blocked on GitHub.
const val REPO_URL = "https://github.com/Myzel394/Alibi"
const val TRANSLATION_HELP_URL = "https://crowdin.com/project/alibi"
const val GITHUB_SPONSORS_URL = "https://github.com/sponsors/Myzel394"
const val PUBLIC_KEY = """-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEZTfvnhYJKwYBBAHaRw8BAQdAi2AiLsTaBoLhnQtY5vi3xBU/H428wbNfBSe+
2dhz3r60Jk15emVsMzk0IDxnaXRodWIuN2Eyb3BAc2ltcGxlbG9naW4uY28+iJkE
ExYKAEEWIQR9BS8nNHwqrNgV0B3NE0dCwel5WQUCZTfvngIbAwUJEswDAAULCQgH
AgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRDNE0dCwel5WcS8AQCf9g6eEaut1suW
l6jCLIg3b1nWLckmLJaonM6PruUtigEAmVnFOxMpOZEIcILT8CD2Riy+IVN9gTNH
qOHnaFsu8AK4OARlN++eEgorBgEEAZdVAQUBAQdAe4ffDtRundKH9kam746i2TBu
P9sfb3QVi5QqfK+bek8DAQgHiH4EGBYKACYWIQR9BS8nNHwqrNgV0B3NE0dCwel5
WQUCZTfvngIbDAUJEswDAAAKCRDNE0dCwel5WWwSAQDj4ZAl6bSqwbcptEMYQaPM
MMhMafm446MjkhQioeXw+wEAzA8mS6RBx7IZvu1dirmFHXOEYJclwjyQhNs4uEjq
/Ak=
=ICHe
-----END PGP PUBLIC KEY BLOCK-----"""
const val PUBLIC_KEY_FINGERPRINT = "7D05 2F27 347C 2AAC D815 D01D CD13 4742 C1E9 7959"
val CRYPTO_DONATIONS = mapOf(
"Bitcoin" to "bc1qw054829yj8e2u8glxnfcg3w22dkek577mjt5x6",
"Bitcoin Cash" to "qr9s64vfqedvurfef9ykf7szchmt0xyvnga452fc8l",
"Ethereum" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
"Tether USD" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
"Monero" to "83dm5wyuckG4aPbuMREHCEgLNwVn5i7963SKBhECaA7Ueb7DKBTy639R3QfMtb3DsFHMp8u6WGiCFgbdRDBBcz5sLduUtm8",
"Zcash" to "t1ZfvNpzfdaW6csT9Kc7iJA7LUU3hmNj2sx",
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
"Dash" to "XcTkni8CVAXBcuc5VwvHmsYftVK4CPLetU",
"Avalanche" to "0xbb5E631c03C65334d1d9EfBCD926DC1265CC20D7",
"XRP" to "rNpfDm8UwDTumCebchBadjVW2FEPteFgNg",
"Solana" to "2h6CB3hz5Vb2nYS1RQiXZ4aWTzc5frBPR7Sp1b4muFqb",
"ADA" to "addr1q8vy2vcp6lacaw8lkc29gufuzajaytc5qc0c2mxlmw5lndxcg5esr4lm36u0lds523cnc9m96gh3gpsls4kdlkaflx6qf6qpvc",
"Dogecoin" to "DUA4j7mVoc7Rvezu8YgeRKwxNuMzKeDoxD",
"Tron" to "THWVLGhne5wDsGjd1CNenHDKQGzvGzrzLb",
"Polkadot" to "1642iaR6AoKyM6qnnMHkfCRfRqRKJ2wC6Cm3UEWEFEz6EtZR",
"Cosmos" to "cosmos1vt5z6rfj5sgnkdlddkuu8srw3xupyqxscva9hz",
"Algorand" to "QBOQ6VSLMD77QEF33P5J3HKGOM5RZLNO6P5P3FTWCMQM3ORF6QY2W34KUI",
"Tezos" to "tz1QUWNYuFqDibGCrwmkdaHSpTx3d6ZdxLMi",
"Litecoin" to "LZayhTosZ9ToRvcbeR1gEDgb76Z7ZA2drN",
"Filecoin" to "f1j6pm3chzhgadpf6iwmtux33jb5gccj5arkg4dsq",
)
// Base64encoding these values so that bots can't easily scrape them.
val b64d = Base64.getDecoder()
val CONTACT_METHODS = mapOf<String, String>(
"E-Mail" to String(b64d.decode("Z2" + "9vZ2xlLXBsYX" + "k" + "uMjlrMWFAYWxlZWFzL" + "mNvbQo=")).trim(),
"GitHub" to String(
b64d.decode(
"aHR" +
"0cHM6Ly9n" + "a" + "XRodWIuY29t" + "L015emVsMzk0L2NvbnRhY3QtbWUK"
)
).trim(),
"Mastodon" to String(b64d.decode("T" + "X" + "l6Z" + "WwzOTRAbWFzdG9kb24uc29" + "jaWFsCg" + "==")).trim(),
"Reddit" to "https://reddit.com/u/Myzel394"
)

View 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))
}
}
)
}
}

View File

@ -5,34 +5,37 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.LastRecording
import app.myzel394.alibi.ui.enums.Screen
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.screens.AudioRecorder
import app.myzel394.alibi.ui.models.VideoRecorderModel
import app.myzel394.alibi.ui.screens.AboutScreen
import app.myzel394.alibi.ui.screens.CustomRecordingNotificationsScreen
import app.myzel394.alibi.ui.screens.RecorderScreen
import app.myzel394.alibi.ui.screens.SettingsScreen
import app.myzel394.alibi.ui.screens.WelcomeScreen
const val SCALE_IN = 1.25f
const val DEBUG_SKIP_WELCOME = false;
@Composable
fun Navigation(
audioRecorder: AudioRecorderModel = viewModel()
audioRecorder: AudioRecorderModel = viewModel(),
videoRecorder: VideoRecorderModel = viewModel(),
) {
val navController = rememberNavController()
val context = LocalContext.current
@ -44,9 +47,11 @@ fun Navigation(
DisposableEffect(Unit) {
audioRecorder.bindToService(context)
videoRecorder.bindToService(context)
onDispose {
audioRecorder.unbindFromService(context)
videoRecorder.unbindFromService(context)
}
}
@ -54,10 +59,18 @@ fun Navigation(
modifier = Modifier
.background(MaterialTheme.colorScheme.background),
navController = navController,
startDestination = if (settings.hasSeenOnboarding) Screen.AudioRecorder.route else Screen.Welcome.route,
startDestination = if (settings.hasSeenOnboarding || DEBUG_SKIP_WELCOME) Screen.AudioRecorder.route else Screen.Welcome.route,
) {
composable(Screen.Welcome.route) {
WelcomeScreen(navController = navController)
WelcomeScreen(
onNavigateToAudioRecorderScreen = {
val mainHandler = ContextCompat.getMainExecutor(context)
mainHandler.execute {
navController.navigate(Screen.AudioRecorder.route)
}
},
)
}
composable(
Screen.AudioRecorder.route,
@ -71,9 +84,13 @@ fun Navigation(
scaleOut(targetScale = SCALE_IN) + fadeOut(tween(durationMillis = 150))
}
) {
AudioRecorder(
navController = navController,
RecorderScreen(
onNavigateToSettingsScreen = {
navController.navigate(Screen.Settings.route)
},
audioRecorder = audioRecorder,
videoRecorder = videoRecorder,
settings = settings,
)
}
composable(
@ -86,8 +103,43 @@ fun Navigation(
}
) {
SettingsScreen(
navController = navController,
onBackNavigate = navController::popBackStack,
onNavigateToCustomRecordingNotifications = {
navController.navigate(Screen.CustomRecordingNotifications.route)
},
onNavigateToAboutScreen = { navController.navigate(Screen.About.route) },
audioRecorder = audioRecorder,
videoRecorder = videoRecorder,
)
}
composable(
Screen.CustomRecordingNotifications.route,
enterTransition = {
slideInHorizontally(
initialOffsetX = { it -> it / 2 }
) + fadeIn()
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { it -> it / 2 }
) + fadeOut(tween(150))
}
) {
CustomRecordingNotificationsScreen(
onBackNavigate = navController::popBackStack
)
}
composable(
Screen.About.route,
enterTransition = {
scaleIn()
},
exitTransition = {
scaleOut() + fadeOut(tween(150))
}
) {
AboutScreen(
onBackNavigate = navController::popBackStack,
)
}
}

View 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.

View File

@ -0,0 +1,184 @@
package app.myzel394.alibi.ui.components.AboutScreen.atoms
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.filled.CurrencyFranc
import androidx.compose.material.icons.filled.CurrencyLira
import androidx.compose.material.icons.filled.CurrencyPound
import androidx.compose.material.icons.filled.CurrencyRuble
import androidx.compose.material.icons.filled.CurrencyRupee
import androidx.compose.material.icons.filled.CurrencyYen
import androidx.compose.material.icons.filled.CurrencyYuan
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.CRYPTO_DONATIONS
import app.myzel394.alibi.ui.GITHUB_SPONSORS_URL
import app.myzel394.alibi.ui.PUBLIC_KEY
@Composable
fun DonationsTile() {
var donationsOpened by rememberSaveable {
mutableStateOf(false)
}
val label = stringResource(R.string.ui_about_contribute_donatation)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = label
}
.clickable {
donationsOpened = !donationsOpened
}
.background(
MaterialTheme.colorScheme.surfaceVariant
)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
listOf(
Icons.Default.CurrencyBitcoin,
Icons.Default.CurrencyFranc,
Icons.Default.CurrencyLira,
Icons.Default.CurrencyPound,
Icons.Default.CurrencyRuble,
Icons.Default.CurrencyRupee,
Icons.Default.CurrencyYen,
Icons.Default.CurrencyYuan,
).asSequence().shuffled().first(),
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize.times(1.2f))
)
Text(
stringResource(R.string.ui_about_contribute_donatation),
fontWeight = FontWeight.Bold,
)
}
val rotation by animateFloatAsState(
if (donationsOpened) -180f else 0f,
label = "iconRotation"
)
Icon(
Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize.times(1.2f))
.rotate(rotation)
)
}
val clipboardManager =
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
AnimatedVisibility(
visible = donationsOpened,
enter = expandVertically(),
) {
Column {
val uriHandler = LocalUriHandler.current
TextButton(
onClick = {
uriHandler.openUri(GITHUB_SPONSORS_URL)
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(R.drawable.ic_github),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize.times(1.2f))
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(
stringResource(R.string.ui_about_contribute_donation_githubSponsors)
)
}
for (crypto in CRYPTO_DONATIONS) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable {
val clip = ClipData.newPlainText("text", crypto.value)
clipboardManager.setPrimaryClip(clip)
}
.padding(16.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = null,
)
Text(
crypto.key,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
)
Text(
crypto.value,
fontSize = MaterialTheme.typography.bodyMedium.fontSize.times(0.5),
)
}
}
}
}
}

View File

@ -0,0 +1,77 @@
package app.myzel394.alibi.ui.components.AboutScreen.atoms
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.PUBLIC_KEY_FINGERPRINT
import app.myzel394.alibi.ui.PUBLIC_KEY
@Composable
fun GPGKeyOverview() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(
MaterialTheme.colorScheme.primaryContainer
)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
Icons.Default.Key,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Text(
stringResource(R.string.ui_about_gpg_key_hint),
style = MaterialTheme.typography.bodyMedium,
)
val clipboardManager =
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
Text(
PUBLIC_KEY_FINGERPRINT,
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.background(
MaterialTheme.colorScheme.surfaceVariant
)
.padding(8.dp),
)
TextButton(
onClick = {
val clip = ClipData.newPlainText("text", PUBLIC_KEY)
clipboardManager.setPrimaryClip(clip)
},
modifier = Modifier
.fillMaxWidth()
) {
Text(stringResource(R.string.ui_about_gpg_key_copy))
}
}
}

View File

@ -1,110 +0,0 @@
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import kotlinx.coroutines.launch
import java.io.File
@Composable
fun SaveRecordingButton(
modifier: Modifier = Modifier,
service: RecorderService,
onSaveFile: (File) -> Unit,
label: String = stringResource(R.string.ui_audioRecorder_action_save_label),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isProcessingAudio by remember { mutableStateOf(false) }
if (isProcessingAudio)
AlertDialog(
onDismissRequest = { },
icon = {
Icon(
Icons.Default.Memory,
contentDescription = null,
)
},
title = {
Text(
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_title),
)
},
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
stringResource(R.string.ui_audioRecorder_action_save_processing_dialog_description),
)
Spacer(modifier = Modifier.height(32.dp))
LinearProgressIndicator()
}
},
confirmButton = {}
)
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE)
.semantics {
contentDescription = label
}
.then(modifier),
onClick = {
isProcessingAudio = true
scope.launch {
try {
} catch (error: Exception) {
Log.getStackTraceString(error)
} finally {
isProcessingAudio = false
}
}
},
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(label)
}
}

View File

@ -1,219 +0,0 @@
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LargeFloatingActionButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.ConfirmDeletionDialog
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.RealtimeAudioVisualizer
import app.myzel394.alibi.ui.components.AudioRecorder.atoms.SaveRecordingButton
import app.myzel394.alibi.ui.components.atoms.Pulsating
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.KeepScreenOn
import app.myzel394.alibi.ui.utils.formatDuration
import kotlinx.coroutines.delay
import java.io.File
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
@Composable
fun RecordingStatus(
audioRecorder: AudioRecorderModel,
) {
val context = LocalContext.current
var now by remember { mutableStateOf(LocalDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
now = LocalDateTime.now()
delay(900)
}
}
// Only show animation when the recording has just started
val recordingJustStarted = audioRecorder.recordingTime!! <= 1000L
var progressVisible by remember { mutableStateOf(!recordingJustStarted) }
LaunchedEffect(Unit) {
progressVisible = true
}
KeepScreenOn()
Column(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Box {}
RealtimeAudioVisualizer(audioRecorder = audioRecorder)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Pulsating {
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(Color.Red)
)
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = formatDuration(audioRecorder.recordingTime!!),
style = MaterialTheme.typography.headlineLarge,
)
}
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(
visible = progressVisible,
enter = expandHorizontally(
tween(1000)
)
) {
LinearProgressIndicator(
progress = audioRecorder.progress,
modifier = Modifier
.width(300.dp)
)
}
Spacer(modifier = Modifier.height(32.dp))
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
ConfirmDeletionDialog(
onDismiss = {
showDeleteDialog = false
},
onConfirm = {
showDeleteDialog = false
audioRecorder.stopRecording(context, saveAsLastRecording = false)
},
)
}
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
},
onClick = {
showDeleteDialog = true
},
colors = ButtonDefaults.textButtonColors(),
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(label)
}
}
val pauseLabel = stringResource(R.string.ui_audioRecorder_action_pause_label)
val resumeLabel = stringResource(R.string.ui_audioRecorder_action_resume_label)
LargeFloatingActionButton(
modifier = Modifier
.semantics {
contentDescription = if (audioRecorder.isPaused) resumeLabel else pauseLabel
},
onClick = {
if (audioRecorder.isPaused) {
audioRecorder.resumeRecording()
} else {
audioRecorder.pauseRecording()
}
},
) {
Icon(
if (audioRecorder.isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = null,
)
}
val alpha by animateFloatAsState(if (progressVisible) 1f else 0f, tween(1000))
val label = stringResource(R.string.ui_audioRecorder_action_save_label)
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE)
.alpha(alpha)
.semantics {
contentDescription = label
},
onClick = {
runCatching {
audioRecorder.stopRecording(context)
}
audioRecorder.onRecordingSave()
},
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_audioRecorder_action_save_label))
}
}
}

View File

@ -1,147 +0,0 @@
package app.myzel394.alibi.ui.components.AudioRecorder.molecules
import android.Manifest
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.BIG_PRIMARY_BUTTON_SIZE
import app.myzel394.alibi.ui.components.atoms.PermissionRequester
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun StartRecording(
audioRecorder: AudioRecorderModel,
) {
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(1f))
PermissionRequester(
permission = Manifest.permission.RECORD_AUDIO,
icon = Icons.Default.Mic,
onPermissionAvailable = {
audioRecorder.startRecording(context)
},
) { trigger ->
val label = stringResource(R.string.ui_audioRecorder_action_start_label)
Button(
onClick = {
trigger()
},
modifier = Modifier
.semantics {
contentDescription = label
}
.size(200.dp)
.clip(shape = CircleShape),
colors = ButtonDefaults.outlinedButtonColors(),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Default.Mic,
contentDescription = null,
modifier = Modifier
.size(80.dp),
)
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
Text(
label,
style = MaterialTheme.typography.titleSmall,
)
}
}
}
val settings = LocalContext
.current
.dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
Text(
stringResource(R.string.ui_audioRecorder_action_start_description, settings.audioRecorderSettings.maxDuration / 1000 / 60),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
modifier = Modifier
.widthIn(max = 300.dp)
.fillMaxWidth(),
textAlign = TextAlign.Center,
)
if (audioRecorder.lastRecording != null && audioRecorder.lastRecording!!.hasRecordingAvailable) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Bottom,
) {
val label = stringResource(
R.string.ui_audioRecorder_action_saveOldRecording_label,
DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).format(audioRecorder.lastRecording!!.recordingStart),
)
Button(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE)
.semantics {
contentDescription = label
},
colors = ButtonDefaults.textButtonColors(),
onClick = {
audioRecorder.stopRecording(context)
audioRecorder.onRecordingSave()
},
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(label)
}
}
}
else
Spacer(modifier = Modifier.weight(1f))
}
}

View File

@ -0,0 +1,110 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowRightAlt
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.utils.openNotificationsSettings
@Composable
fun LandingElement(
modifier: Modifier = Modifier,
onOpenEditor: () -> Unit,
) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp, vertical = 64.dp)
.then(modifier),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Box() {}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(id = R.drawable.ic_custom_recording_notifications_blob),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.tertiaryContainer),
contentDescription = null,
modifier = Modifier
.width(512.dp)
)
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.size(128.dp)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(R.string.ui_settings_customNotifications_landing_title),
style = MaterialTheme.typography.headlineMedium,
)
Text(
stringResource(R.string.ui_settings_customNotifications_landing_description),
style = MaterialTheme.typography.bodySmall,
)
FilledTonalButton(onClick = onOpenEditor) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize)
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(
stringResource(
R.string.ui_settings_customNotifications_landing_getStarted
)
)
}
}
}
TextButton(
onClick = context::openNotificationsSettings,
) {
Text(
stringResource(R.string.ui_settings_customNotifications_landing_help_hideNotifications),
)
}
}
}

View File

@ -0,0 +1,61 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.db.NotificationSettings
@Composable
fun NotificationPresetSelect(
modifier: Modifier = Modifier,
preset: NotificationSettings.Preset
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.clip(MaterialTheme.shapes.large)
.then(modifier)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.border(
width = 1.dp,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)
)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
PreviewIcon(
modifier = Modifier.size(32.dp),
painter = painterResource(id = preset.iconID),
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = stringResource(preset.titleID),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringResource(preset.messageID),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
)
}
}
}

View File

@ -0,0 +1,42 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
@Composable
fun PreviewIcon(
modifier: Modifier = Modifier,
painter: Painter,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.then(modifier)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondary)
.padding(2.dp)
) {
Image(
painter = painter,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary),
)
}
}

View File

@ -0,0 +1,80 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import app.myzel394.alibi.R
import app.myzel394.alibi.db.NotificationSettings
class NotificationViewModel : ViewModel() {
// We want to show the actual translated strings of the preset
// in the preview but don't want to save them to the database
// because they should be retrieved in the notification itself.
// Thus we save whether the preset has been changed by the user
private var _presetChanged = false
private var _title = mutableStateOf("")
val title: String
get() = _title.value
private var _description = mutableStateOf("")
val description: String
get() = _description.value
var showOngoing: Boolean by mutableStateOf(true)
var icon: Int by mutableIntStateOf(R.drawable.launcher_monochrome_noopacity)
// `preset` can't be used as a variable name here because
// the compiler throws a strange error then
var notificationPreset: NotificationSettings.Preset? by mutableStateOf(null)
private var _hasBeenInitialized = false;
fun setPreset(title: String, description: String, preset: NotificationSettings.Preset) {
_presetChanged = false
_title.value = title
_description.value = description
showOngoing = preset.showOngoing
icon = preset.iconID
this.notificationPreset = preset
}
fun setTitle(title: String) {
_presetChanged = true
_title.value = title
}
fun setDescription(description: String) {
_presetChanged = true
_description.value = description
}
fun initialize(
title: String,
description: String,
showOngoing: Boolean = true,
icon: Int = R.drawable.launcher_monochrome_noopacity,
) {
_title.value = title
_description.value = description
this.showOngoing = showOngoing
this.icon = icon
_hasBeenInitialized = true
}
fun asNotificationSettings(): NotificationSettings {
return if (!_presetChanged && notificationPreset != null) {
NotificationSettings.fromPreset(notificationPreset!!)
} else {
NotificationSettings(
title = title,
message = description,
iconID = icon,
showOngoing = showOngoing,
)
}
}
}

View File

@ -0,0 +1,171 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.PreviewIcon
import app.myzel394.alibi.ui.effects.rememberForceUpdate
import java.time.Duration
import java.time.LocalDateTime
@Composable
fun EditNotificationInput(
modifier: Modifier = Modifier,
showOngoing: Boolean,
title: String,
description: String,
icon: Painter,
onShowOngoingChange: (Boolean) -> Unit,
onTitleChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onIconChange: (Int) -> Unit,
) {
var ongoingStartTime by remember { mutableStateOf(LocalDateTime.now()) }
val secondaryColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
LaunchedEffect(showOngoing) {
if (showOngoing) {
ongoingStartTime = LocalDateTime.now()
}
}
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
.padding(16.dp)
.then(modifier),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
val headlineSize = 22.dp
PreviewIcon(
modifier = Modifier.size(headlineSize),
painter = icon,
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.height(headlineSize),
) {
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.bodySmall,
color = secondaryColor,
)
if (showOngoing) {
Icon(
Icons.Default.Circle,
contentDescription = null,
tint = secondaryColor,
modifier = Modifier
.size(8.dp)
)
val fakeAlpha = rememberForceUpdate()
val formattedTime = {
val difference =
Duration.between(
ongoingStartTime,
LocalDateTime.now(),
)
val minutes = difference.toMinutes()
val seconds = difference.minusMinutes(minutes).seconds
"${if (minutes < 10) "0$minutes" else minutes}:${if (seconds < 10) "0$seconds" else seconds}"
}
Text(
formattedTime(),
modifier = Modifier.alpha(fakeAlpha),
style = MaterialTheme.typography.bodySmall,
color = secondaryColor,
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
BasicTextField(
value = title,
onValueChange = onTitleChange,
textStyle = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
)
BasicTextField(
value = description,
onValueChange = onDescriptionChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurfaceVariant),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done,
),
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(R.string.ui_recorder_action_delete_label),
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
)
Text(
stringResource(R.string.ui_recorder_action_pause_label),
color = MaterialTheme.colorScheme.secondary,
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontWeight = FontWeight.Bold,
)
}
}
}
}

View File

@ -0,0 +1,71 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.db.NotificationSettings
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.atoms.NotificationPresetSelect
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NotificationPresetsRoulette(
onClick: (String, String, NotificationSettings.Preset) -> Unit,
) {
val state = rememberLazyListState()
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
state = state,
flingBehavior = rememberSnapFlingBehavior(lazyListState = state)
) {
items(NotificationSettings.PRESETS.size) {
val preset = NotificationSettings.PRESETS[it]
val label = stringResource(
R.string.ui_settings_customNotifications_preset_apply_label,
stringResource(preset.titleID)
)
val presetTitle = stringResource(preset.titleID)
val presetDescription = stringResource(preset.messageID)
Box(
modifier = Modifier.width(
LocalConfiguration.current.screenWidthDp.dp,
)
) {
NotificationPresetSelect(
modifier = Modifier
.fillMaxWidth(.95f)
.align(Alignment.Center)
.semantics {
contentDescription = label
}
.clickable {
onClick(
presetTitle,
presetDescription,
preset,
)
},
preset = preset,
)
}
}
}
}

View File

@ -0,0 +1,203 @@
package app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.organisms
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.NotificationSettings
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.models.NotificationViewModel
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.EditNotificationInput
import app.myzel394.alibi.ui.components.CustomRecordingNotificationsScreen.molecules.NotificationPresetsRoulette
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
val HORIZONTAL_PADDING = 16.dp;
@Composable
fun NotificationEditor(
modifier: Modifier = Modifier,
notificationModel: NotificationViewModel = viewModel(),
onNotificationChange: (NotificationSettings) -> Unit,
) {
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
if (settings.notificationSettings != null) {
val title = settings.notificationSettings.let {
if (it.preset != null)
stringResource(it.preset.titleID)
else
it.title
}
val description = settings.notificationSettings.let {
if (it.preset != null)
stringResource(it.preset.messageID)
else
it.message
}
LaunchedEffect(Unit) {
notificationModel.initialize(
title,
description,
settings.notificationSettings.showOngoing,
settings.notificationSettings.iconID,
)
if (settings.notificationSettings.preset != null) {
notificationModel.setPreset(
title,
description,
settings.notificationSettings.preset
)
}
}
} else {
val defaultTitle = stringResource(R.string.ui_audioRecorder_state_recording_title)
val defaultDescription =
stringResource(R.string.ui_recorder_state_recording_description)
LaunchedEffect(Unit) {
notificationModel.initialize(
defaultTitle,
defaultDescription,
)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.then(modifier),
verticalArrangement = Arrangement.SpaceBetween,
) {
Column(
modifier = Modifier
.padding(horizontal = HORIZONTAL_PADDING),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
MessageBox(
type = MessageType.SURFACE,
message = stringResource(R.string.ui_settings_customNotifications_edit_help)
)
EditNotificationInput(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
showOngoing = notificationModel.showOngoing,
title = notificationModel.title,
description = notificationModel.description,
icon = painterResource(notificationModel.icon),
onShowOngoingChange = {
notificationModel.showOngoing = it
},
onTitleChange = notificationModel::setTitle,
onDescriptionChange = notificationModel::setDescription,
onIconChange = {
notificationModel.icon = it
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable {
notificationModel.showOngoing = notificationModel.showOngoing.not()
}
.background(MaterialTheme.colorScheme.tertiaryContainer)
.padding(8.dp),
) {
Checkbox(
checked = notificationModel.showOngoing,
onCheckedChange = {
notificationModel.showOngoing = it
},
colors = CheckboxDefaults.colors(
checkedColor = MaterialTheme.colorScheme.tertiary,
checkmarkColor = MaterialTheme.colorScheme.onTertiary,
)
)
Text(
text = stringResource(R.string.ui_settings_customNotifications_showOngoing_label),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
fontWeight = FontWeight.Bold,
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
NotificationPresetsRoulette(
onClick = notificationModel::setPreset,
)
Button(
onClick = {
onNotificationChange(
notificationModel.asNotificationSettings()
)
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = HORIZONTAL_PADDING)
.height(48.dp),
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
)
Spacer(
modifier = Modifier
.width(ButtonDefaults.IconSpacing)
)
Text(
stringResource(R.string.ui_settings_customNotifications_save_label)
)
}
}
}
}

View File

@ -1,4 +1,4 @@
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth

View File

@ -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))
}
}
)
}

View File

@ -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,
)
}
}
}
}

View File

@ -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
},
)
}
}

View File

@ -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,
)

View File

@ -1,4 +1,4 @@
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
@ -11,6 +11,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -28,10 +29,10 @@ fun ConfirmDeletionDialog(
onDismiss()
},
title = {
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_title))
Text(stringResource(R.string.ui_recorder_action_delete_confirm_title))
},
text = {
Text(stringResource(R.string.ui_audioRecorder_action_delete_confirm_message))
Text(stringResource(R.string.ui_recorder_action_delete_confirm_message))
},
icon = {
Icon(
@ -40,12 +41,13 @@ fun ConfirmDeletionDialog(
)
},
confirmButton = {
val label = stringResource(R.string.ui_audioRecorder_action_delete_label)
val label = stringResource(R.string.ui_recorder_action_delete_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
onClick = {
onConfirm()
},
@ -61,15 +63,15 @@ fun ConfirmDeletionDialog(
},
dismissButton = {
val label = stringResource(R.string.dialog_close_cancel_label)
Button(
TextButton(
modifier = Modifier
.semantics {
contentDescription = label
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
onClick = {
onDismiss()
},
colors = ButtonDefaults.textButtonColors(),
) {
Icon(
Icons.Default.Cancel,

View File

@ -0,0 +1,53 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import app.myzel394.alibi.R
@Composable
fun DeleteButton(
modifier: Modifier = Modifier,
onDelete: () -> Unit,
) {
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
ConfirmDeletionDialog(
onDismiss = {
showDeleteDialog = false
},
onConfirm = {
showDeleteDialog = false
onDelete()
},
)
}
val label = stringResource(R.string.ui_recorder_action_delete_label)
TextButton(
modifier = Modifier
.semantics {
contentDescription = label
}
.then(modifier),
onClick = {
showDeleteDialog = true
},
) {
Text(
label,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
)
}
}

View File

@ -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
)
}
}
}

View File

@ -0,0 +1,63 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import app.myzel394.alibi.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MicrophoneDisconnectedDialog(
microphoneName: String,
onClose: () -> Unit,
) {
AlertDialog(
onDismissRequest = onClose,
title = {
Text(
stringResource(
R.string.ui_audioRecorder_error_microphoneDisconnected_title,
),
textAlign = TextAlign.Center,
)
},
text = {
Text(
stringResource(
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
microphoneName,
microphoneName,
)
)
},
icon = {
Icon(
Icons.Default.MicOff,
contentDescription = null,
)
},
confirmButton = {
val label = stringResource(R.string.dialog_close_neutral_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
},
onClick = onClose,
) {
Text(label)
}
}
)
}

View File

@ -0,0 +1,62 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import app.myzel394.alibi.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MicrophoneReconnectedDialog(
microphoneName: String,
onClose: () -> Unit,
) {
AlertDialog(
onDismissRequest = onClose,
title = {
Text(
stringResource(
R.string.ui_audioRecorder_error_microphoneReconnected_title,
),
textAlign = TextAlign.Center,
)
},
text = {
Text(
stringResource(
R.string.ui_audioRecorder_error_microphoneReconnected_message,
microphoneName,
)
)
},
icon = {
Icon(
Icons.Default.Star,
contentDescription = null,
)
},
confirmButton = {
val label = stringResource(R.string.dialog_close_neutral_label)
Button(
modifier = Modifier
.semantics {
contentDescription = label
},
onClick = onClose,
) {
Text(label)
}
}
)
}

View File

@ -0,0 +1,86 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MicExternalOn
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.ui.utils.MicrophoneInfo
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
@Composable
fun MicrophoneSelectionButton(
microphone: MicrophoneInfo? = null,
selected: Boolean = false,
selectedAsFallback: Boolean = false,
disabled: Boolean = false,
onSelect: () -> Unit,
) {
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
// Copied from Android's [FilledButtonTokens]
val disabledTextColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
Button(
onClick = onSelect,
enabled = !disabled,
modifier = Modifier
.fillMaxWidth()
.height(64.dp),
colors = if (selected) ButtonDefaults.buttonColors() else ButtonDefaults.textButtonColors(),
contentPadding = if (selected) ButtonDefaults.ButtonWithIconContentPadding else ButtonDefaults.TextButtonContentPadding,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(ButtonDefaults.IconSpacing),
) {
MicrophoneTypeInfo(
type = microphone?.type ?: MicrophoneInfo.MicrophoneType.PHONE,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Column {
Text(
text = microphone?.name
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone),
fontSize = MaterialTheme.typography.bodyLarge.fontSize,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && settings.audioRecorderSettings.showAllMicrophones && microphone?.deviceInfo?.address?.isNotBlank() == true)
Text(
microphone.deviceInfo.address.toString(),
fontSize = MaterialTheme.typography.bodySmall.toSpanStyle().fontSize,
color = if (disabled) disabledTextColor else if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.secondary,
)
}
if (selectedAsFallback)
Icon(
Icons.Default.MicExternalOn,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier
.size(ButtonDefaults.IconSize),
)
}
}
}

View File

@ -0,0 +1,28 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BluetoothAudio
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicExternalOn
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.myzel394.alibi.ui.utils.MicrophoneInfo
@Composable
fun MicrophoneTypeInfo(
modifier: Modifier = Modifier,
type: MicrophoneInfo.MicrophoneType,
) {
Icon(
imageVector = when (type) {
MicrophoneInfo.MicrophoneType.BLUETOOTH -> Icons.Filled.BluetoothAudio
MicrophoneInfo.MicrophoneType.WIRED -> Icons.Filled.MicExternalOn
MicrophoneInfo.MicrophoneType.PHONE -> Icons.Filled.Smartphone
MicrophoneInfo.MicrophoneType.OTHER -> Icons.Filled.Mic
},
modifier = modifier,
contentDescription = null,
)
}

View File

@ -0,0 +1,37 @@
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import app.myzel394.alibi.R
@Composable
fun PauseResumeButton(
modifier: Modifier = Modifier,
isPaused: Boolean,
onChange: () -> Unit,
) {
val pauseLabel = stringResource(R.string.ui_recorder_action_pause_label)
val resumeLabel = stringResource(R.string.ui_recorder_action_resume_label)
FloatingActionButton(
modifier = Modifier
.semantics {
contentDescription = if (isPaused) resumeLabel else pauseLabel
}
.then(modifier),
onClick = onChange,
) {
Icon(
if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
contentDescription = null,
)
}
}

View File

@ -1,16 +1,19 @@
package app.myzel394.alibi.ui.components.AudioRecorder.atoms
package app.myzel394.alibi.ui.components.RecorderScreen.atoms
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.gestures.rememberTransformableState
import androidx.compose.foundation.gestures.transformable
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
@ -19,7 +22,6 @@ import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.services.RecorderService
import app.myzel394.alibi.ui.MAX_AMPLITUDE
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.clamp
@ -35,10 +37,11 @@ private const val GROW_END = BOX_DIFF * 4
@Composable
fun RealtimeAudioVisualizer(
modifier: Modifier = Modifier,
audioRecorder: AudioRecorderModel,
) {
val scope = rememberCoroutineScope()
val amplitudes = audioRecorder.amplitudes!!
val amplitudes = audioRecorder.amplitudes
val primary = MaterialTheme.colorScheme.primary
val primaryMuted = primary.copy(alpha = 0.3f)
@ -63,17 +66,28 @@ fun RealtimeAudioVisualizer(
}
val configuration = LocalConfiguration.current
val screenWidth = with (LocalDensity.current) {configuration.screenWidthDp.dp.toPx()}
LaunchedEffect(screenWidth) {
// Add 1 to allow the visualizer to overflow the screen
audioRecorder.setMaxAmplitudesAmount(ceil(screenWidth.toInt() / BOX_DIFF).toInt() + 1)
// Use greater value of width and height to make sure the amplitudes are shown
// when the user rotates the device
val availableSpace = with(LocalDensity.current) {
Math.max(
configuration.screenWidthDp.dp.toPx(),
configuration.screenHeightDp.dp.toPx()
)
}
LaunchedEffect(availableSpace) {
// Add 1 to allow the visualizer to overflow the screen
audioRecorder.setMaxAmplitudesAmount(ceil(availableSpace.toInt() / BOX_DIFF).toInt() + 1)
}
var scale by remember { mutableFloatStateOf(1f) }
val transformState = rememberTransformableState { zoomChange, _, _ ->
scale *= zoomChange
}
val amplitudePercentageModifier = MAX_AMPLITUDE * (1 / scale)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
modifier = modifier.transformable(transformState),
) {
val height = this.size.height / 2f
val width = this.size.width
@ -87,8 +101,10 @@ fun RealtimeAudioVisualizer(
val horizontalProgress = (
clamp(horizontalValue, GROW_START, GROW_END)
- GROW_START) / (GROW_END - GROW_START)
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
val boxHeight = (height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
val amplitudePercentage =
(amplitude.toFloat() / amplitudePercentageModifier).coerceAtMost(1f)
val boxHeight =
(height * amplitudePercentage * horizontalProgress).coerceAtLeast(15f)
drawRoundRect(
color = if (amplitudePercentage > 0.05f && isOverThreshold) primary else primaryMuted,

View File

@ -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))
}
}
)
}

View File

@ -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 = {}
)
}

View File

@ -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,
)
}
}

View File

@ -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))
}
}
}
}

View File

@ -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),
)
}
}

View File

@ -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,
)
}
}
}

View File

@ -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]!!,
)
}
}
}
}

View File

@ -0,0 +1,223 @@
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.SHEET_BOTTOM_OFFSET
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneSelectionButton
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneTypeInfo
import app.myzel394.alibi.ui.components.atoms.MessageBox
import app.myzel394.alibi.ui.components.atoms.MessageType
import app.myzel394.alibi.ui.models.AudioRecorderModel
import app.myzel394.alibi.ui.utils.MicrophoneInfo
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MicrophoneSelection(
audioRecorder: AudioRecorderModel
) {
val context = LocalContext.current
var showSelection by rememberSaveable {
mutableStateOf(false)
}
val sheetState = rememberModalBottomSheetState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
val allMicrophones = MicrophoneInfo.fetchDeviceMicrophones(context)
val visibleMicrophones = MicrophoneInfo.filterMicrophones(allMicrophones)
val hiddenMicrophones = allMicrophones - visibleMicrophones.toSet()
val isTryingToReconnect =
audioRecorder.selectedMicrophone != null && audioRecorder.microphoneStatus == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED
val shownMicrophones = if (isTryingToReconnect && visibleMicrophones.isEmpty()) {
listOf(audioRecorder.selectedMicrophone!!)
} else {
visibleMicrophones
}
val scope = rememberCoroutineScope()
fun hideSheet() {
scope.launch {
sheetState.hide()
showSelection = false
}
}
if (showSelection) {
ModalBottomSheet(
onDismissRequest = ::hideSheet,
sheetState = sheetState,
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = SHEET_BOTTOM_OFFSET),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(48.dp),
) {
Text(
stringResource(R.string.ui_audioRecorder_info_microphone_changeExplanation),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
)
if (isTryingToReconnect)
MessageBox(
type = MessageType.INFO,
message = stringResource(
R.string.ui_audioRecorder_error_microphoneDisconnected_message,
audioRecorder.selectedMicrophone?.name ?: "",
audioRecorder.selectedMicrophone?.name ?: "",
)
)
LazyColumn(
modifier = Modifier
.padding(horizontal = 32.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
MicrophoneSelectionButton(
selected = audioRecorder.selectedMicrophone == null,
selectedAsFallback = isTryingToReconnect,
onSelect = {
audioRecorder.changeMicrophone(null)
hideSheet()
}
)
}
items(shownMicrophones.size) {
val microphone = shownMicrophones[it]
MicrophoneSelectionButton(
microphone = microphone,
selected = audioRecorder.selectedMicrophone == microphone,
disabled = isTryingToReconnect && microphone == audioRecorder.selectedMicrophone,
onSelect = {
audioRecorder.changeMicrophone(microphone)
hideSheet()
}
)
}
if (settings.audioRecorderSettings.showAllMicrophones) {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(vertical = 32.dp),
) {
HorizontalDivider(
modifier = Modifier
.weight(1f)
)
Text(
stringResource(R.string.ui_audioRecorder_info_microphone_hiddenMicrophones),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
textAlign = TextAlign.Center,
)
HorizontalDivider(
modifier = Modifier
.weight(1f),
)
}
}
items(hiddenMicrophones.size) {
val microphone = hiddenMicrophones[it]
MicrophoneSelectionButton(
microphone = microphone,
selected = audioRecorder.selectedMicrophone == microphone,
onSelect = {
audioRecorder.changeMicrophone(microphone)
hideSheet()
}
)
}
}
}
}
}
} else {
// We need to show a placeholder box to keep the the rest aligned correctly
Box {}
}
if (shownMicrophones.isNotEmpty() || (settings.audioRecorderSettings.showAllMicrophones && hiddenMicrophones.isNotEmpty())) {
TextButton(
onClick = {
scope.launch {
showSelection = true
sheetState.show()
}
},
contentPadding = ButtonDefaults.TextButtonWithIconContentPadding,
) {
MicrophoneTypeInfo(
type = audioRecorder.selectedMicrophone?.type
?: MicrophoneInfo.MicrophoneType.PHONE,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text(
text = audioRecorder.selectedMicrophone.let {
it?.name
?: stringResource(R.string.ui_audioRecorder_info_microphone_deviceMicrophone)
}
)
if (isTryingToReconnect) {
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
}
}
}
}

View File

@ -0,0 +1,55 @@
package app.myzel394.alibi.ui.components.RecorderScreen.molecules
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneDisconnectedDialog
import app.myzel394.alibi.ui.components.RecorderScreen.atoms.MicrophoneReconnectedDialog
import app.myzel394.alibi.ui.effects.rememberPrevious
import app.myzel394.alibi.ui.models.AudioRecorderModel
@Composable
fun MicrophoneStatus(
audioRecorder: AudioRecorderModel,
) {
val microphoneStatus = audioRecorder.microphoneStatus
val previousStatus = rememberPrevious(microphoneStatus)
var showMicrophoneStatusDialog by remember {
// null = no dialog
// `MicrophoneConnectivityStatus.CONNECTED` = Reconnected dialog
// `MicrophoneConnectivityStatus.DISCONNECTED` = Disconnected dialog
mutableStateOf<AudioRecorderModel.MicrophoneConnectivityStatus?>(null)
}
LaunchedEffect(microphoneStatus) {
if (microphoneStatus != previousStatus && showMicrophoneStatusDialog == null && previousStatus != null && audioRecorder.selectedMicrophone != null) {
showMicrophoneStatusDialog = microphoneStatus
}
}
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.DISCONNECTED) {
MicrophoneDisconnectedDialog(
onClose = {
showMicrophoneStatusDialog = null
},
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
)
}
if (showMicrophoneStatusDialog == AudioRecorderModel.MicrophoneConnectivityStatus.CONNECTED) {
MicrophoneReconnectedDialog(
onClose = {
showMicrophoneStatusDialog = null
},
microphoneName = audioRecorder.selectedMicrophone?.name ?: "",
)
}
MicrophoneSelection(
audioRecorder = audioRecorder,
)
}

View File

@ -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))
}
}
}
}
}
}

View File

@ -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,
)
}
}
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -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,
)
}
}
}
}
}
}
}
}

View File

@ -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,
)
}
}

View File

@ -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
},
)
}

View File

@ -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
},
)
}

View File

@ -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
)
}
}
}

View File

@ -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()
}
},
)
}
}
}

View File

@ -0,0 +1,69 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import app.myzel394.alibi.ui.enums.Screen
@Composable
fun AboutTile(
onNavigateToAboutScreen: () -> Unit,
) {
val label = stringResource(R.string.ui_about_title)
Row(
modifier = Modifier
.padding(horizontal = 32.dp, vertical = 48.dp)
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.semantics {
contentDescription = label
}
.clickable {
onNavigateToAboutScreen()
}
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Info,
contentDescription = null,
)
Text(
text = label,
)
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
)
}
}

View File

@ -1,8 +1,7 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -11,7 +10,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -36,14 +34,12 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitrateTile() {
fun AudioRecorderBitrateTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
fun updateValue(bitRate: Int) {
scope.launch {
@ -81,11 +77,11 @@ fun BitrateTile() {
val bitRate = text?.toIntOrNull()
if (bitRate == null) {
ValidationResult.Invalid(notNumberLabel)
return@InputTextField ValidationResult.Invalid(notNumberLabel)
}
if (bitRate !in 1..320) {
ValidationResult.Invalid(notInRangeLabel)
return@InputTextField ValidationResult.Invalid(notInRangeLabel)
}
ValidationResult.Valid
@ -94,7 +90,9 @@ fun BitrateTile() {
)
),
) { result ->
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException("Bitrate is null")
val bitRate = result.getString("bitrate")?.toIntOrNull() ?: throw IllegalStateException(
"Bitrate is null"
)
updateValue(bitRate * 1000)
}

View File

@ -1,8 +1,7 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import android.media.MediaRecorder
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -13,7 +12,6 @@ import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -34,18 +32,16 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EncoderTile(
snackbarHostState: SnackbarHostState
fun AudioRecorderEncoderTile(
snackbarHostState: SnackbarHostState,
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
val updatedOutputFormatLabel = stringResource(R.string.ui_settings_option_encoder_extra_outputFormatChanged)
val updatedOutputFormatLabel =
stringResource(R.string.ui_settings_option_encoder_extra_outputFormatChanged)
fun updateValue(encoder: Int?) {
scope.launch {
@ -92,7 +88,7 @@ fun EncoderTile(
selected = settings.audioRecorderSettings.encoder == index,
)
}.toList()
) {index, option ->
) { index, _ ->
updateValue(index)
},
)

View File

@ -1,6 +1,5 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import android.media.MediaRecorder
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AudioFile
import androidx.compose.material3.Button
@ -8,13 +7,8 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -35,14 +29,12 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OutputFormatTile() {
fun AudioRecorderOutputFormatTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
val availableOptions = if (settings.audioRecorderSettings.encoder == null)
AudioRecorderSettings.OUTPUT_FORMAT_INDEX_TEXT_MAP.keys.toTypedArray()
else AudioRecorderSettings.ENCODER_SUPPORTED_OUTPUT_FORMATS_MAP[settings.audioRecorderSettings.encoder]!!
@ -74,7 +66,7 @@ fun OutputFormatTile() {
selected = settings.audioRecorderSettings.outputFormat == option,
)
}.toList()
) {index, option ->
) { index, _ ->
updateValue(availableOptions[index])
},
)

View File

@ -1,9 +1,8 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@ -11,7 +10,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -36,14 +34,12 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SamplingRateTile() {
fun AudioRecorderSamplingRateTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
fun updateValue(samplingRate: Int?) {
scope.launch {
@ -62,7 +58,8 @@ fun SamplingRateTile() {
header = Header.Default(
title = stringResource(R.string.ui_settings_option_samplingRate_title),
icon = IconSource(
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked).asPainterResource(),
painter = IconResource.fromImageVector(Icons.Default.RadioButtonChecked)
.asPainterResource(),
contentDescription = null,
)
),
@ -81,11 +78,11 @@ fun SamplingRateTile() {
val samplingRate = text?.toIntOrNull()
if (samplingRate == null) {
ValidationResult.Invalid(notNumberLabel)
return@InputTextField ValidationResult.Invalid(notNumberLabel)
}
if (samplingRate!! <= 1000) {
ValidationResult.Invalid(mustBeGreaterThanLabel)
if (samplingRate <= 1000) {
return@InputTextField ValidationResult.Invalid(mustBeGreaterThanLabel)
}
ValidationResult.Valid
@ -94,7 +91,8 @@ fun SamplingRateTile() {
)
),
) { result ->
val samplingRate = result.getString("samplingRate")?.toIntOrNull() ?: throw IllegalStateException("SamplingRate is null")
val samplingRate = result.getString("samplingRate")?.toIntOrNull()
?: throw IllegalStateException("SamplingRate is null")
updateValue(samplingRate)
}
@ -117,7 +115,8 @@ fun SamplingRateTile() {
shape = MaterialTheme.shapes.medium,
) {
Text(
(settings.audioRecorderSettings.samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
(settings.audioRecorderSettings.samplingRate
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
)
}
},
@ -127,7 +126,8 @@ fun SamplingRateTile() {
onItemSelected = ::updateValue,
) { samplingRate ->
Text(
(samplingRate ?: stringResource(R.string.ui_settings_value_auto_label)).toString()
(samplingRate
?: stringResource(R.string.ui_settings_value_auto_label)).toString()
)
}
}

View File

@ -1,11 +1,10 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.MicExternalOn
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -13,25 +12,21 @@ import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import com.maxkeppeker.sheets.core.models.base.rememberUseCaseState
import kotlinx.coroutines.launch
@Composable
fun ForceExactMaxDurationTile() {
fun AudioRecorderShowAllMicrophonesTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
fun updateValue(forceExactMaxDuration: Boolean) {
fun updateValue(showAllMicrophones: Boolean) {
scope.launch {
dataStore.updateData {
it.setAudioRecorderSettings(
it.audioRecorderSettings.setForceExactMaxDuration(forceExactMaxDuration)
it.audioRecorderSettings.setShowAllMicrophones(showAllMicrophones)
)
}
}
@ -39,17 +34,17 @@ fun ForceExactMaxDurationTile() {
SettingsTile(
title = stringResource(R.string.ui_settings_option_forceExactDuration_title),
description = stringResource(R.string.ui_settings_option_forceExactDuration_description),
title = stringResource(R.string.ui_settings_option_showAllMicrophones_title),
description = stringResource(R.string.ui_settings_option_showAllMicrophones_description),
leading = {
Icon(
Icons.Default.GraphicEq,
Icons.Default.MicExternalOn,
contentDescription = null,
)
},
trailing = {
Switch(
checked = settings.audioRecorderSettings.forceExactMaxDuration,
checked = settings.audioRecorderSettings.showAllMicrophones,
onCheckedChange = ::updateValue,
)
},

View File

@ -0,0 +1,57 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.navigation.NavController
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import app.myzel394.alibi.ui.enums.Screen
@Composable
fun CustomNotificationTile(
onNavigateToCustomRecordingNotifications: () -> Unit,
settings: AppSettings,
) {
val dataStore = LocalContext.current.dataStore
val label = if (settings.notificationSettings == null)
stringResource(R.string.ui_settings_option_customNotification_description_setup)
else stringResource(
R.string.ui_settings_option_customNotification_description_edit
)
SettingsTile(
firstModifier = Modifier
.clickable {
onNavigateToCustomRecordingNotifications()
}
.semantics { contentDescription = label },
title = stringResource(R.string.ui_settings_option_customNotification_title),
description = label,
leading = {
Icon(
Icons.Default.Notifications,
contentDescription = null,
)
},
trailing = {
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
)
}
)
}

View File

@ -0,0 +1,48 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import kotlinx.coroutines.launch
@Composable
fun DeleteRecordingsImmediatelyTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
SettingsTile(
title = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_title),
description = stringResource(R.string.ui_settings_option_deleteRecordingsImmediately_description),
leading = {
Icon(
Icons.Default.DeleteSweep,
contentDescription = null,
)
},
trailing = {
Switch(
checked = settings.deleteRecordingsImmediately,
onCheckedChange = {
scope.launch {
dataStore.updateData {
it.setDeleteRecordingsImmediately(it.deleteRecordingsImmediately.not())
}
}
}
)
}
)
}

View File

@ -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
)
}
}
}
)
}
)
}

View File

@ -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,
)
}
}
}

View File

@ -0,0 +1,164 @@
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import app.myzel394.alibi.R
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
import app.myzel394.alibi.ui.utils.rememberFileSelectorDialog
import kotlinx.coroutines.launch
import java.io.File
@Composable
fun ImportExport(
snackbarHostState: SnackbarHostState,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
var settingsToBeImported by remember { mutableStateOf<AppSettings?>(null) }
val saveFile = rememberFileSaverDialog("application/json")
val openFile = rememberFileSelectorDialog { uri ->
val file = File.createTempFile("alibi_settings", ".json")
context.contentResolver.openInputStream(uri)!!.use {
it.copyTo(file.outputStream())
}
val rawContent = file.readText()
settingsToBeImported = AppSettings.fromExportedString(rawContent)
}
if (settingsToBeImported != null) {
val successMessage = stringResource(R.string.ui_settings_option_import_success)
AlertDialog(
onDismissRequest = {
settingsToBeImported = null
},
title = {
Text(stringResource(R.string.ui_settings_option_import_label))
},
text = {
Text(stringResource(R.string.ui_settings_option_import_dialog_text))
},
icon = {
Icon(
Icons.Default.Download,
contentDescription = null,
)
},
confirmButton = {
Button(
onClick = {
scope.launch {
dataStore.updateData {
settingsToBeImported!!
}
settingsToBeImported = null
snackbarHostState.showSnackbar(
message = successMessage,
withDismissAction = true,
duration = SnackbarDuration.Short,
)
}
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_import_dialog_confirm))
}
},
dismissButton = {
TextButton(
onClick = {
settingsToBeImported = null
},
) {
Text(stringResource(R.string.dialog_close_cancel_label))
}
},
)
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
FilledTonalButton(
onClick = {
openFile("application/json")
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_import_label))
}
FilledTonalButton(
onClick = {
val rawContent = settings.exportToString()
val tempFile = File.createTempFile("alibi_settings", ".json")
tempFile.writeText(rawContent)
saveFile(tempFile, "alibi_settings.json")
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.Upload,
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.ui_settings_option_export_label))
}
}
}

View File

@ -1,8 +1,7 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -34,21 +32,23 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IntervalDurationTile() {
fun IntervalDurationTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
fun updateValue(intervalDuration: Long) {
scope.launch {
if (intervalDuration > settings.maxDuration) {
dataStore.updateData {
it.setAudioRecorderSettings(
it.audioRecorderSettings.setIntervalDuration(intervalDuration)
)
it.setMaxDuration(intervalDuration)
}
}
dataStore.updateData {
it.setIntervalDuration(intervalDuration)
}
}
}
@ -67,7 +67,7 @@ fun IntervalDurationTile() {
},
config = DurationConfig(
timeFormat = DurationFormat.MM_SS,
currentTime = settings.audioRecorderSettings.intervalDuration / 1000,
currentTime = settings.intervalDuration / 1000,
minTime = 10,
maxTime = 60 * 60,
)
@ -90,7 +90,7 @@ fun IntervalDurationTile() {
shape = MaterialTheme.shapes.medium,
) {
Text(
text = formatDuration(settings.audioRecorderSettings.intervalDuration),
text = formatDuration(settings.intervalDuration),
)
}
},

View File

@ -1,7 +1,6 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
package app.myzel394.alibi.ui.components.SettingsScreen.Tiles
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -10,7 +9,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -33,21 +31,23 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MaxDurationTile() {
fun MaxDurationTile(
settings: AppSettings,
) {
val scope = rememberCoroutineScope()
val showDialog = rememberUseCaseState()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
fun updateValue(maxDuration: Long) {
scope.launch {
if (maxDuration < settings.intervalDuration) {
dataStore.updateData {
it.setAudioRecorderSettings(
it.audioRecorderSettings.setMaxDuration(maxDuration)
)
it.setIntervalDuration(maxDuration)
}
}
dataStore.updateData {
it.setMaxDuration(maxDuration)
}
}
}
@ -65,10 +65,10 @@ fun MaxDurationTile() {
updateValue(newTimeInSeconds * 1000L)
},
config = DurationConfig(
timeFormat = DurationFormat.MM_SS,
currentTime = settings.audioRecorderSettings.maxDuration / 1000,
timeFormat = DurationFormat.HH_MM,
currentTime = settings.maxDuration / 1000,
minTime = 60,
maxTime = 24 * 60 * 60,
maxTime = 23 * 60 * 60 + 59 * 60,
)
)
SettingsTile(
@ -88,7 +88,7 @@ fun MaxDurationTile() {
),
shape = MaterialTheme.shapes.medium,
) {
Text(formatDuration(settings.audioRecorderSettings.maxDuration))
Text(formatDuration(settings.maxDuration))
}
},
extra = {

View File

@ -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)
}
}

View File

@ -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,
)
}
}

View File

@ -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,
)
}

View File

@ -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)
)
}
}
)
}

View File

@ -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))
}
},
)
}

View File

@ -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,
)
}
}
}

View File

@ -24,6 +24,7 @@ import androidx.compose.ui.res.stringResource
import androidx.core.os.LocaleListCompat
import app.myzel394.alibi.R
import app.myzel394.alibi.SUPPORTED_LOCALES
import app.myzel394.alibi.db.AppSettings
import app.myzel394.alibi.db.AudioRecorderSettings
import app.myzel394.alibi.ui.components.atoms.SettingsTile
import app.myzel394.alibi.ui.utils.IconResource

View File

@ -0,0 +1,173 @@
package app.myzel394.alibi.ui.components.SettingsScreen.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import app.myzel394.alibi.dataStore
import app.myzel394.alibi.db.AppSettings
import kotlinx.coroutines.launch
@Composable
fun Preview(
modifier: Modifier = Modifier,
backgroundColor: Color,
primaryColor: Color,
textColor: Color,
onSelect: () -> Unit,
isSelected: Boolean = false,
) {
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(100.dp)
.height(200.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(width = 1.dp, color = textColor, shape = RoundedCornerShape(10.dp))
.background(backgroundColor)
.clickable { onSelect() },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(10.dp)
) {
Box(
modifier = Modifier
.width(30.dp)
.height(10.dp)
.clip(shape = RoundedCornerShape(10.dp))
.background(primaryColor)
)
Box(
modifier = Modifier
.size(10.dp)
.clip(shape = RoundedCornerShape(10.dp))
.background(primaryColor)
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
Icons.Default.Mic,
contentDescription = null,
tint = primaryColor,
)
Box(
modifier = Modifier
.width(40.dp)
.height(6.dp)
.clip(shape = RoundedCornerShape(10.dp))
.background(primaryColor)
)
Box(
modifier = Modifier
.width(75.dp)
.height(10.dp)
.clip(shape = RoundedCornerShape(10.dp))
.background(textColor)
)
}
Box {}
}
if (isSelected) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize(),
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(30.dp),
)
}
}
}
}
@Composable
fun ThemeSelector() {
val scope = rememberCoroutineScope()
val dataStore = LocalContext.current.dataStore
val settings = dataStore
.data
.collectAsState(initial = AppSettings.getDefaultInstance())
.value
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Preview(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
backgroundColor = Color(0xFFF0F0F0),
primaryColor = Color(0xFFAAAAAA),
textColor = Color(0xFFCCCCCC),
onSelect = {
scope.launch {
dataStore.updateData {
it.setTheme(AppSettings.Theme.LIGHT)
}
}
},
isSelected = settings.theme == AppSettings.Theme.LIGHT,
)
Preview(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
backgroundColor = Color(0xFF444444),
primaryColor = Color(0xFF888888),
textColor = Color(0xFF606060),
onSelect = {
scope.launch {
dataStore.updateData {
it.setTheme(AppSettings.Theme.DARK)
}
}
},
isSelected = settings.theme == AppSettings.Theme.DARK,
)
}
}

View File

@ -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),
)
}
}
}
}
}

View File

@ -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,
)
}
}
}

View File

@ -1,4 +1,4 @@
package app.myzel394.alibi.ui.components.WelcomeScreen.atoms
package app.myzel394.alibi.ui.components.WelcomeScreen.pages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -64,6 +64,7 @@ fun ExplanationPage(
.padding(16.dp)
.fillMaxWidth()
.height(BIG_PRIMARY_BUTTON_SIZE),
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Default.ChevronRight,

Some files were not shown because too many files have changed in this diff Show More