Compare commits

...

235 Commits

Author SHA1 Message Date
88bd746359
Add .avif extension 2024-10-29 11:58:43 +01:00
9872cab7c2
Add m4v extension 2024-09-24 20:16:34 +02:00
4aa211bca4
Fix gocryptfs_jni.c invalid string access 2024-08-22 23:01:35 +02:00
0a1406769b
Fix ABI versionCode offsets 2024-07-26 13:36:45 +02:00
a62f32e364
Update README.md & TODO.md 2024-07-25 17:18:29 +02:00
f865c864a2
Add changelog & Update screenshots & description 2024-07-25 17:16:57 +02:00
e804059b23
Add Hebrew translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-07-25 16:59:30 +02:00
solokot
bb821d5f41
Update Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-07-25 13:15:25 +02:00
6c0e20c68e
Disable usfExpose when disabling usfBackground 2024-07-24 17:57:20 +02:00
e9b67bd9c4
Update dependencies 2024-07-22 21:19:12 +02:00
c0dcaed8d2
Improve volume adding UI 2024-07-22 21:06:29 +02:00
85e24921fa
Replace file systems dropdown with radio buttons 2024-07-18 23:50:05 +02:00
15f288be11
Auto gocryptfs cipher by default & Fix FileOperationsService notification permission request 2024-07-18 23:50:04 +02:00
bb49501403
OpenSSL & FFmpeg as submodules & Different versionCode for each ABI 2024-07-18 23:49:51 +02:00
33d565bf22
KeepAlive foreground service 2024-07-16 15:03:44 +02:00
52a29b034c
Target Android 13 & Make FileOperationService a foreground service 2024-06-15 16:39:40 +02:00
d44601f69f
Restore upstream video player controls & Update dependencies 2024-06-10 23:39:52 +02:00
4b002c7b24
Fix SecurityException when importing from exposed volume 2024-06-07 16:07:20 +02:00
7c72c4e829
Update dependencies & Fix build 2024-06-06 21:08:11 +02:00
bd60e62635
Allow importing from ClipData 2024-06-03 16:11:27 +02:00
CyanWolf
d1e042c347
Update Spanish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-05-31 19:26:05 +02:00
sjceel
0805ebda35
Add Chinese-Simplified translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-05-31 19:11:53 +02:00
intergalacticmonkey
36e6ad99b3
Fix typo in Turkish strings.xml
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-05-23 13:49:58 +02:00
solokot
967d4551c5
Update Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-12 16:51:55 +01:00
Ali Beyaz
b747d2822a
Add Turkish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-12 16:47:30 +01:00
CyanWolf
e5652666d8
Update Spanish
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-11 18:12:17 +01:00
Muhmmad14333653
cda0e90b96
Update Arabic translations
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-11 18:08:54 +01:00
6f43bc7417
Avoid being killed by SELinux when retrieving volume path 2024-02-11 17:55:24 +01:00
c26ab661c2
Logcat activity 2024-01-30 18:29:49 +01:00
1c15f9fac8
Allow choosing export method 2024-01-28 15:44:53 +01:00
b4635dc2e0
Directory loading indicator 2024-01-13 23:19:22 +01:00
f4e47c1827
Allow directory creation on exposed volumes 2024-01-13 21:41:58 +01:00
5474d6eea5
Add .opus & Update build config 2024-01-13 21:25:31 +01:00
719faa31ee
Fix README 2023-10-15 17:09:48 +02:00
a41cde1c53
DroidFS v2.1.3 2023-09-28 19:36:55 +02:00
b503f134d5
Fix Intent.getParcelableExtra() crash on Android 13 2023-09-24 19:04:49 +02:00
3ba774fda3
Add Version.toString() 2023-09-19 13:47:59 +02:00
b2154d319e
Repair corrupted database due to v2.1.1 2023-09-19 13:39:35 +02:00
571a79cc1d
Really fix database upgrade 2023-09-19 11:41:01 +02:00
891a581329
Update dependencies 2023-09-17 20:10:15 +02:00
f1a9c1383c
Fix database upgrade 2023-09-17 19:11:52 +02:00
ac71ad887d
Fix README 2023-09-10 21:39:28 +02:00
e1fe329f49
Add v2.1.0 changelog 2023-09-10 21:11:04 +02:00
dfff597ae5
DroidFS v2.1.0 2023-09-10 21:01:39 +02:00
bd429648b3
Update documentation 2023-09-10 21:01:04 +02:00
71ff37b170
Fixes 2023-09-10 19:17:51 +02:00
4afe56b13c
Migrate to AndroidX Media3 2023-09-10 19:12:17 +02:00
217334a959
Fix es & de translations 2023-09-09 16:15:03 +02:00
CyanWolf
2666313676
Update Spanish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-09-08 21:32:46 +02:00
solokot
04e154a6d9
Updated Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-09-08 21:30:28 +02:00
Torsten Pfützenreuter
d3760e2194
Added German translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-09-08 21:27:38 +02:00
d6c777875e
Fix VolumeProvider createDocument path 2023-09-08 21:22:20 +02:00
8a18270b33
Update dependencies 2023-09-08 21:13:24 +02:00
79db84f81d
Volume provider 2023-09-06 19:27:41 +02:00
6d04349b2e
Prevent volume renaming when open 2023-09-06 19:27:04 +02:00
de0194a722
Always open volume after creation 2023-08-20 17:08:10 +02:00
3127a15d9e
Fix ANR on recursive mapping 2023-08-20 16:42:40 +02:00
a08da2eacb
MemoryFileProvider 2023-08-20 14:56:46 +02:00
1727170cb6
Limit the number of thumbnails loaded concurrently 2023-08-15 18:33:29 +02:00
8776d2ee28
Add Support section in README 2023-08-15 18:06:39 +02:00
5642e28b44
Fix TODO.md 2023-05-12 20:39:58 +02:00
1b7e5904be
New screenshots 2023-05-12 20:26:45 +02:00
cb3fc3c70e
Re-ask only on wrong password 2023-05-11 21:58:55 +02:00
393c458495
Offload file discovery for copy in coroutine 2023-05-11 21:24:29 +02:00
cdf98a7190
Handle cryfs inaccessible base dir 2023-05-11 00:02:05 +02:00
2ae41f0f79
Improve file oprations coroutines 2023-05-10 23:41:29 +02:00
Muhmmad14333653
f85f9d1c44
Update arabic translation 2023-05-08 21:36:22 +02:00
9fc981fee8
Fix rotation when rebinding camera use cases 2023-05-08 21:32:04 +02:00
ad19b9e645
Update dependencies 2023-05-08 20:58:54 +02:00
87ffbc3cc1
Fix unsafe features doc link 2023-05-06 23:57:23 +02:00
b3a25e03e7
Improve video recording: fix freezes & ExoPlayer errors 2023-05-06 23:40:37 +02:00
4c412be7dc
Best error messages when opening volumes 2023-05-03 14:14:40 +02:00
f4f3239bb1
Fix volume copying 2023-05-02 14:24:59 +02:00
481558bd56
Add ecryptfs & shufflecake in TODO & Update README 2023-04-29 20:21:46 +02:00
8d0a797469
v2.0.1 changelog 2023-04-26 17:12:38 +02:00
a4ce35c95d
WiperService 2023-04-26 16:40:05 +02:00
e51bd2ceba
TODO.md & Update dependencies 2023-04-26 16:02:07 +02:00
2bbf003df5
Recover unregistered hidden volumes 2023-04-25 15:06:20 +02:00
e83cfc9794
Fix password encoding 2023-04-24 13:37:14 +02:00
ctntt
9d1bfd606f
Themed/monochrome icon support
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-04-23 11:43:13 +02:00
49ec2eaf49
Stop always opening files in write mode 2023-04-20 16:38:15 +02:00
8c9c6a20b9
Really fix proguard-rules.pro 2023-04-19 16:23:32 +02:00
f6d1fc8b67
Fix gradle nosplits 2023-04-19 16:11:54 +02:00
solokot
de3a1a9538
Updated Russian v2 translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-04-19 15:36:05 +02:00
0a089c46ca
Fix proguard-rules.pro 2023-04-19 15:34:36 +02:00
05f4610407
v2.0.0 2023-04-18 19:47:44 +02:00
CyanWolf
451f36c770
Update Spanish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-04-18 15:23:01 +02:00
df3f84f526
Target Android API level 32 2023-04-18 14:59:05 +02:00
24215a8b31
Fix crash when default volume gets deleted 2023-04-18 13:53:40 +02:00
eb4e13af46
Disable settings buttons during video recording 2023-04-17 18:50:46 +02:00
aea17aa7cb
Update dependencies 2023-04-17 17:06:51 +02:00
e918a2f94c
New CameraX API 2023-04-17 15:52:20 +02:00
e6761d1798
Update README & fastlane full_description.txt 2023-03-15 18:08:39 +01:00
c434d79c06
Fix video title switching 2023-03-13 17:10:06 +01:00
821c853a22
Hide navigation bar in full screen mode 2023-03-13 17:02:38 +01:00
22b1522192
Optional password fallback 2023-03-08 12:03:05 +01:00
5090a7aa03
Separate color selection & black theme 2023-03-08 11:43:13 +01:00
1a1d3ea570
Multi volume openings 2023-03-07 23:25:17 +01:00
2d165c4a20
Monospace font in text editor 2023-02-06 11:16:10 +01:00
883874a5ab
Refactoring: Constants & FileTypes 2023-02-06 10:52:51 +01:00
6e500c23e5
Adaptive icon 2023-02-05 14:43:30 +01:00
a726f7a7d0
Fix storage permission requests 2023-02-02 21:09:11 +01:00
1e75e9a32f
Clear password fields onStop() 2023-02-02 19:37:10 +01:00
5e9656970a
Update dependencies 2023-02-01 23:46:27 +01:00
5dbef99949
Fix EncryptedVolumeDataSource EOF 2023-02-01 20:06:35 +01:00
d2f11c85d1
Android 11 support 2023-02-01 19:09:53 +01:00
25dbcca854
Update libpdfviewer & Fix FFmpeg build & onBackPressed deprecation in AddVolumeActivity 2022-10-08 11:07:59 +02:00
CyanWolf
545275dabc
Update spanish translation 2022-10-06 18:32:54 +02:00
solokot
077f5cc856
Update russian translation 2022-10-06 18:32:53 +02:00
Muhmmad14333653
2e07ee5333
Update arabic translation 2022-10-06 18:32:53 +02:00
34aad2596d
Async file loading in file viewers 2022-10-04 13:30:51 +02:00
cdc269f2f7
Add pink themes 2022-10-04 12:57:23 +02:00
991e435e5e
Fix file viewers navigation bar color in dark mode 2022-10-04 12:23:25 +02:00
7c2f87109a
Allow to open & create volumes without remembering 2022-09-30 21:22:37 +02:00
4df1086734
More flexible password change when fingerprint is saved 2022-09-27 18:33:43 +02:00
7cdfc32c31
Direct encrypted files read/write & More compliant EncryptedVolumeDataSource 2022-09-23 20:58:16 +02:00
8f5afca823
Update dependencies & Fix some bugs 2022-09-23 12:09:22 +02:00
11cc15536f
Add FLAG_GRANT_READ_URI_PERMISSION for external opens 2022-09-13 12:43:08 +02:00
2d19895e6d
Truncate files after possible overwrite 2022-09-13 10:33:14 +02:00
e2539a53b9
Set navigation bar color to background color 2022-07-03 14:11:53 +02:00
17c32f2144
Show volume type in MainActivity 2022-07-03 13:43:46 +02:00
a5b6de1138
BUILD.md: prefer cloning from the Chapril repository 2022-06-30 22:06:33 +02:00
d1ca164934
Allow changing password of CryFS volumes 2022-06-30 21:43:40 +02:00
1a21a43f05
Gocryptfs JNI cleanup 2022-06-29 22:18:11 +02:00
4d164944c1
Deleting files in background 2022-06-29 15:17:36 +02:00
8709abd7d7
Don't cancel file operations when changing configuration 2022-06-29 14:47:50 +02:00
e01932acda
Allow opening CryFS volumes with password hash 2022-06-29 13:43:56 +02:00
cf4927a90b
CryFS 2022-06-26 15:51:48 +02:00
CyanWolf
cb5679515c
Update spanish translation
Fix little error
2022-06-26 14:09:52 +02:00
Muhmmad14333653
a728bd8d24
Update arabic translation 2022-06-26 14:07:41 +02:00
83dd759f36
Fix local reference table overflow in native_list_dir 2022-05-24 18:05:58 +02:00
5144947a4a
Media player fixes: better handling of RTL & orientation 2022-05-07 14:51:56 +02:00
Muhmmad14333653
6b52eed9d0
update arabic translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2022-05-05 13:20:18 +02:00
Muhmmad14333653
2a257d91d0
update Arabic translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2022-05-03 13:08:17 +02:00
f837556af5
Fix explorer info bar color in dark mode 2022-05-02 14:17:02 +02:00
b7ab267d16
Arabic translation 2022-05-02 14:11:02 +02:00
5ea0b8ad41
Actually fix camera icon tint bug 2022-05-01 19:48:16 +02:00
ec348383c6
Fix camera icon tint bug 2022-05-01 16:59:18 +02:00
c8d266150c
Fix explorer menu display 2022-05-01 13:50:37 +02:00
CyanWolf
4bbc9360b4
Update Spanish translation
Added the latest translations and syntax improvements.

Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2022-04-22 12:19:46 +02:00
8aa2be2b05
Update libgocryptfs & Small UX fix 2022-04-22 12:13:21 +02:00
e2248220c4
DroidFS v1.10.0 2022-04-21 16:03:53 +02:00
7959b20b3f
Update libgocryptfs 2022-04-21 15:50:36 +02:00
solokot
8cebe499f0
Updated Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2022-04-21 15:17:08 +02:00
cyanwolfg
a22b9d8fa8
Update Spanish translation
Add some missing lines and new translations.

Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2022-04-21 15:14:08 +02:00
cba1418417
Fix move operation 2022-04-21 15:09:48 +02:00
b6b8bba666
Save checkbox state if opening volume fails 2022-04-21 10:46:31 +02:00
e00abdf5bb
Switch to Kotlin coroutines 2022-04-20 15:17:33 +02:00
72cce1d7e1
Show a fingerprint icon when password hash is saved 2022-04-18 14:55:59 +02:00
55b0ac0daa
Prefill text field with current name when renaming a volume 2022-04-18 14:54:34 +02:00
53f28e9475
Pin passwords 2022-04-17 17:38:49 +02:00
f1d4b07726
Show total number of selected items 2022-04-17 17:03:25 +02:00
339309b00d
Fix image viewer rotation handling 2022-04-16 16:45:19 +02:00
e6a1285e0a
Fix camera output rotation 2022-04-16 13:58:58 +02:00
ab48f9219b
CameraActivity: only bind 2 use cases at most + some other fixes 2022-04-12 16:09:56 +02:00
c521c7f998
Open default volume on application startup 2022-04-11 15:29:28 +02:00
1d13dfbde3
Always show total size & Add some explorer info bar translations 2022-04-10 22:08:33 +02:00
36ab66fb43
Show number of files & folders in current directory 2022-04-10 17:14:38 +02:00
1caabc2554
Fix explorer layouts 2022-04-10 15:53:08 +02:00
f541504e07
Refactor RecyclerView adapters 2022-04-09 20:13:39 +02:00
4de5b41102
Thumbnails cache & Don't do full reload on selection change 2022-04-09 19:28:36 +02:00
4f9aa55dfe
Explorer grid layout 2022-04-09 15:09:14 +02:00
91de54018d
Prompt for password if fingerprint authentification fails 2022-04-02 11:58:25 +02:00
2697eaf11b
Spanish translation 2022-04-02 11:34:27 +02:00
9e69805ade
Update ptbr translation 2022-03-31 18:24:38 +02:00
18d0f50094
Update to libpdfviewer 13 2022-03-26 19:44:14 +01:00
e32e106ce3
Fix video player controls 2022-03-26 19:44:13 +01:00
4608a7a165
Fix MainSettingsFragment crash 2022-03-26 19:44:13 +01:00
985be2de59
Add HEIC to the image extension list 2022-03-26 13:02:11 +01:00
solokot
f07d99efed
Updated Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2022-03-26 12:51:51 +01:00
4a55d826d9
Volume renaming 2022-03-24 20:22:54 +01:00
2ee7a5b871
Allow changing thumbnail max size 2022-03-23 16:39:28 +01:00
72321b8ec5
Switch to StyledPlayerView 2022-03-23 14:56:15 +01:00
7226cc8218
Fix UI bug on too long volume paths 2022-03-21 14:31:24 +01:00
55be5cd0e7
DroidFS v1.9.0 2022-03-08 16:20:42 +01:00
solokot
3c4515e4e9
Update Russian translation 2022-03-08 16:10:10 +01:00
29eb34e1d5
New screenshots 2022-03-07 20:47:50 +01:00
d6f727a142
Fix error message when creating volume on external SD card 2022-03-07 12:58:46 +01:00
6d5fc465c7
Fix UI bug on alert dialogs 2022-03-07 12:30:42 +01:00
ed0b5eb483
Add PDF in OpenAs dialog & libpdfviewer dialog crash fix 2022-03-07 11:05:20 +01:00
fd0296f801
Update libpdfviewer 2022-03-06 21:44:58 +01:00
58391802be
More accurate directory size 2022-03-06 15:56:34 +01:00
e01b5a3098
Volume copy 2022-03-06 15:56:33 +01:00
bea0906f65
Display file name on video player 2022-03-05 19:23:48 +01:00
71a314b0a0
New home UI 2022-03-05 12:51:02 +01:00
842667cdee
Update ptbr translation 2022-02-23 15:54:33 +01:00
e5bcc5cfc2
Update dependencies 2022-02-18 19:39:36 +01:00
32508344fe
Update PGP key 2022-02-18 19:39:16 +01:00
ee3df7c3bf
Icon for PDF files 2022-02-18 17:38:15 +01:00
b18232615d
PDF viewer 2022-02-18 15:53:48 +01:00
83efc53edc
Update PGP keyserver 2022-01-29 19:04:07 +01:00
f546e64c34
ImageViwer: retrieve screen size with Resources.getSystem().displayMetrics 2022-01-23 12:56:37 +01:00
822aba9481
Better build explanation 2022-01-20 14:02:07 +01:00
3007bf756c
Fix video player UI bug 2022-01-20 12:59:05 +01:00
solokot
87cd88232e
Update russian translation 2022-01-20 10:43:37 +01:00
832fd1d34b
DoubleTapPlayerView 2022-01-18 20:59:30 +01:00
3ae7e4df70
Remove DialogSingleChoiceAdapter 2022-01-18 20:42:13 +01:00
55883172a1
Consider WebP as image 2022-01-16 11:55:36 +01:00
b366fa1877
Fill viewport in text editor 2022-01-14 15:50:42 +01:00
95eed07719
Natural file name sorting 2022-01-13 19:23:37 +01:00
f15b17c936
Fix scroll issue on OpenActivity & ChangePasswordActivity 2022-01-12 11:06:56 +01:00
5d6f53b37a
Change icon of mode button when switching between photo & video mode 2022-01-11 20:18:02 +01:00
23d017780f
Flush previous image before loading the next in ImageViewer 2021-12-25 16:13:34 +01:00
bee2997f90
Update dependencies 2021-12-23 19:40:37 +01:00
80c5277936
Reset flash state when switching to back camera in video mode 2021-12-23 19:10:13 +01:00
solokot
a9d4284b43
Updated Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2021-12-23 18:48:43 +01:00
7ca9398766
Fix size formatting 2021-12-21 11:19:06 +01:00
bd4c935c4c
Downscale image when decoding with BitmapFactory 2021-12-21 11:19:06 +01:00
b65ee230be
Fix flash and timer for video recording 2021-12-21 11:19:06 +01:00
8b4adfbe21
Update dependencies 2021-12-21 11:19:05 +01:00
57e93f0b49
Fix video player auto rotation 2021-12-21 11:19:05 +01:00
Hang Hang
be802aa5af
Legacy fullscreen mode option & Button to toggle screen orientation in video player 2021-12-21 11:18:28 +01:00
d1a556b8c6
Invalidate options menu when changing explorer elements 2021-12-18 11:48:05 +01:00
solokot
83525159e3
Updated Russian translation to v1.7.0
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2021-11-13 13:48:56 +01:00
4f8bf860e0
Don't use DayNight themes 2021-11-11 22:42:43 +01:00
2ee0c679fb
Fix bugs when renaming 2021-11-11 20:14:48 +01:00
23a20b7ddb
Fix delete icon color bug 2021-11-11 19:46:26 +01:00
fd5ddc02b1
Thumbnails 2021-11-11 19:21:05 +01:00
e3df7be3b5
Switch explorer from ListView to RecyclerView 2021-11-10 20:03:57 +01:00
65ecdd19ca
Remove some deprecation warnings 2021-11-09 16:40:32 +01:00
d3f0d059f8
Switch from Cyanea to Themes 2021-11-09 16:40:28 +01:00
d572a8d2dc
Update dependencies 2021-11-07 12:55:39 +01:00
01a9c261f5
Fix image viewer 2021-11-07 12:53:38 +01:00
1cab607fa1
Decode images with BitmapFactory instead of Glide 2021-10-23 20:19:33 +02:00
26153d44b9
Update description & build process for F-Droid 2021-10-20 19:29:44 +02:00
solokot
2d0ec3529d
Updated Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2021-10-19 19:11:54 +02:00
e96d4724d3
Adapt to write access at Android/data/sushi.hardcore.droidfs on SD cards 2021-10-19 15:54:38 +02:00
6158b36c9f
Fix fingerprint checkbox behavior 2021-10-19 13:52:06 +02:00
b273fa828b
Upgrade CameraX 2021-10-19 12:02:21 +02:00
5349d40da9
Add "Open as" option in explorer menu 2021-10-19 11:24:20 +02:00
c858d556d5
Exoplayer repeat & shuffle controls 2021-10-17 19:43:03 +02:00
e47d9f4548
XChaCha20-Ploy1305 support 2021-10-17 13:46:10 +02:00
a377b61240
Use FFmpeg shared libraries & Update build instructions 2021-10-16 15:28:02 +02:00
497c22edc1
Fix recorded video rotation 2021-10-12 14:22:35 +02:00
dc89c02b9f
Video recording 2021-10-10 15:58:09 +02:00
305 changed files with 25106 additions and 5474 deletions

9
.gitmodules vendored
View File

@ -1,3 +1,12 @@
[submodule "app/libgocryptfs"] [submodule "app/libgocryptfs"]
path = app/libgocryptfs path = app/libgocryptfs
url = https://forge.chapril.org/hardcoresushi/libgocryptfs.git url = https://forge.chapril.org/hardcoresushi/libgocryptfs.git
[submodule "libpdfviewer"]
path = libpdfviewer
url = https://forge.chapril.org/hardcoresushi/libpdfviewer.git
[submodule "app/libcryfs"]
path = app/libcryfs
url = https://forge.chapril.org/hardcoresushi/libcryfs.git
[submodule "app/ffmpeg/ffmpeg"]
path = app/ffmpeg/ffmpeg
url = https://git.ffmpeg.org/ffmpeg.git

99
BUILD.md Normal file
View File

@ -0,0 +1,99 @@
# Introduction
DroidFS relies on modified versions of the original encrypted filesystems programs to open volumes. [CryFS](https://github.com/cryfs/cryfs) is written in C++ while [gocryptfs](https://github.com/rfjakob/gocryptfs) is written in [Go](https://golang.org). Thus, building DroidFS requires the compilation of native code. However, for the sake of simplicity, the application has been designed in a modular way: you can build a version of DroidFS that supports both Gocryptfs and CryFS, or only one of the two.
Moreover, DroidFS aims to be accessible to as many people as possible. If you encounter any problems or need help with the build, feel free to open an issue, a discussion, or contact me (currently the main developer) by [email](mailto:gh@arkensys.dedyn.io) or on [Matrix](https://matrix.org): @hardcoresushi:matrix.underworld.fr
# Setup
The following two steps assume you're using a Debian-based Linux distribution. Package names might be similar for other distributions. Don't hesitate to ask if you're having trouble with this.
Install required packages:
```
$ sudo apt-get install openjdk-17-jdk-headless build-essential pkg-config git gnupg2 wget apksigner npm
```
You also need to manually install the [Android SDK](https://developer.android.com/studio/index.html#command-tools) and the [Android Native Development Kit (NDK)](https://github.com/android/ndk/wiki/Unsupported-Downloads#r25c) version `25.2.9519653` (r25c). libcryfs cannot be built with newer NDK versions at the moment due to compatibility issues with [boost](https://www.boost.org). If you succeed in building it with a more recent version of NDK, please report it.
If you want a support for Gocryptfs volumes, you need to install [Go](https://golang.org/doc/install):
```
$ sudo apt-get install golang-go
```
The code should be authenticated before being built. To verify the signatures, you will need my PGP key:
```
$ gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys AFE384344A45E13A
```
Fingerprint: `B64E FE86 CEE1 D054 F082 1711 AFE3 8434 4A45 E13A` \
Email: `Hardcore Sushi <hardcore.sushi@disroot.org>`
# Download sources
Download DroidFS source code:
```
$ git clone --depth=1 https://forge.chapril.org/hardcoresushi/DroidFS.git
```
Verify sources:
```
$ cd DroidFS
$ git verify-commit HEAD
```
__Don't continue if the verification fails!__
Initialize submodules:
```
$ git submodule update --init
```
If you want Gocryptfs support, initliaze libgocryptfs submodules:
```
$ cd app/libgocryptfs
$ git submodule update --init
```
If you want CryFS support, initialize libcryfs submodules:
```
$ cd app/libcryfs
$ git submodule update --init
```
# Build
Retrieve your Android NDK installation path, usually something like `/home/\<user\>/Android/SDK/ndk/\<NDK version\>`. Then, make it available in your shell:
```
$ export ANDROID_NDK_HOME="<your ndk path>"
```
If you know your CPU ABI, you can specify it to build scripts in order to speed up compilation time. If you don't know it, or want to build for all ABIs, just leave the field blank.
Start by compiling FFmpeg:
```
$ cd app/ffmpeg
$ ./build.sh [<ABI>]
```
## libgocryptfs
This step is only required if you want Gocryptfs support.
```
$ cd app/libgocryptfs
$ ./build.sh [<ABI>]
```
## Compile APKs
Gradle build libgocryptfs and libcryfs by default.
To build DroidFS without Gocryptfs support, run:
```
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableGocryptfs=true
```
To build DroidFS without CryFS support, run:
```
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableCryFS=true
```
If you want to build DroidFS with support for both Gocryptfs and CryFS, just run:
```
$ ./gradlew assembleRelease [-Pabi=<ABI>]
```
# Sign APKs
If the build succeeds, you will find the unsigned APKs in `app/build/outputs/apk/release/`. These APKs need to be signed in order to be installed on an Android device.
If you don't already have a keystore, you can create a new one by running:
```
$ keytool -genkey -keystore <output file> -alias <key alias> -keyalg EC -validity 10000
```
Then, sign the APK with:
```
$ apksigner sign --out droidfs.apk -v --ks <keystore> app/build/outputs/apk/release/<unsigned apk file>
```
Now you can install `droidfs.apk` on your device.

20
DONATE.txt Normal file
View File

@ -0,0 +1,20 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
Here are the DroidFS donation addresses:
Monero (XMR):
86f82JEMd33WfapNZETukJW17eEa6RR4rW3wNEZ2CAZh228EYpDaar4DdDPUc4U3YT4CcFdW4c7462Uzx9Em2BB92Aj9fbT
Bitcoin (BTC):
bc1qeyvpy3tj4rr4my5f5wz9s8a4g4nh4l0kj4h6xy
-----BEGIN PGP SIGNATURE-----
iHUEARYIAB0WIQS2Tv6GzuHQVPCCFxGv44Q0SkXhOgUCZNuhaAAKCRCv44Q0SkXh
OqEUAP0d67oFlGp5IlBHwNI/p2KMHka3LzHdQTBQs40Jus3tVQEAsTZEy/sc6Nwp
C8mAXUTebijFgrlYYQkfVS0RBXHwggo=
=E6ia
-----END PGP SIGNATURE-----

172
README.md
View File

@ -1,57 +1,86 @@
# DroidFS # DroidFS
DroidFS is an alternative way to use encrypted overlay filesystems on Android that uses its own internal file explorer instead of mounting virtual volumes. An alternative way to use encrypted virtual filesystems on Android that uses its own internal file explorer instead of mounting volumes.
It currently only works with [gocryptfs](https://github.com/rfjakob/gocryptfs) but support for [CryFS](https://github.com/cryfs/cryfs) could be added in the future. It currently supports [gocryptfs](https://github.com/rfjakob/gocryptfs) and [CryFS](https://github.com/cryfs/cryfs).
For mortals: Encrypted storage compatible with already existing softwares.
<p align="center"> <p align="center">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" height="500"> <img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" height="500">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" height="500"> <img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" height="500">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" height="500"> <img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" height="500">
</p> </p>
# Disclamer # Support
The creator of DroidFS works as a freelance developer and privacy consultant. I am currently looking for new clients! If you are interested, take a look at the [website](https://arkensys.dedyn.io). Alternatively, you can directly support DroidFS by making a [donation](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/DONATE.txt).
Thank you so much ❤️.
# Disclaimer
DroidFS is provided "as is", without any warranty of any kind. DroidFS is provided "as is", without any warranty of any kind.
It shouldn't be considered as an absolute safe way to store files. It shouldn't be considered as an absolute safe way to store files.
DroidFS cannot protect you from screen recording apps, keyloggers, apk backdooring, compromised root accesses, memory dumps etc. DroidFS cannot protect you from screen recording apps, keyloggers, apk backdooring, compromised root accesses, memory dumps etc.
Do not use this app with volumes containing sensitive data unless you know exactly what you are doing. Do not use this app with volumes containing sensitive data unless you know exactly what you are doing.
# Features
- Compatible with original encrypted volume implementations
- Internal support for video, audio, images, text and PDF files
- Built-in camera to take on-the-fly encrypted photos and videos
- Unlocking volumes using fingerprint authentication
- Volume auto-locking when the app goes in background
For planned features, see [TODO.md](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/TODO.md).
# Unsafe features # Unsafe features
DroidFS allows you to enable/disable unsafe features to fit your needs between security and comfort. Some available features are considered risky and are therefore disabled by default. It is strongly recommended that you read the following documentation if you wish to activate one of these options.
It is strongly recommended to read the documentation of a feature before enabling it.
<ul> <ul>
<li><h4>Allow screenshots:</h4> <li><b>Allow screenshots:</b>
Disable the secure flag of DroidFS activities. This will allow you to take screenshots from the app, but will also allow other apps to record the screen while using DroidFS. Disable the secure flag of DroidFS activities. This will allow you to take screenshots from the app, but will also allow other apps to record the screen while using DroidFS.
Note: apps with root access don't care about this flag: they can take screenshots or record the screen of any app without any permissions.
</li> Note: apps with root access don't care about this flag: they can take screenshots or record the screen of any app without any permissions.</li>
<li><h4>Allow opening files with other applications *:</h4> <li><b>Allow exporting files:</b>
Decrypt and open file using external apps. These apps could save and send the files thus opened.
</li> Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.</li>
<li><h4>Allow exporting files:</h4> <li><b>Allow sharing files via the android share menu⁽¹⁾:</b>
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.
</li> Decrypt and share file with other apps. These apps could save and send the files thus shared.</li>
<li><h4>Allow sharing files via the android share menu *:</h4> <li><b>Allow saving password hash using fingerprint:</b>
Decrypt and share file with other apps. These apps could save and send the files thus shared.
</li> Generate an AES-256 GCM key in the Android Keystore (protected by fingerprint authentication), then use it to encrypt the volume password hash and store it to the DroidFS internal storage. This require Android v6.0+. If your device is not encrypted, extracting the encryption key with physical access may be possible.</li>
<li><h4>Keep volume open when the app goes in background:</h4> <li><b>Disable volume auto-locking:</b> (previously called <i>"Keep volumes open when the app goes in background"</i>)
Don't close the volume when you leave the app but keep running it in the background. Anyone going back to the activity could have access to the volume.
</li> Don't close open volumes when you leave the app. Anyone going back to the application could have access to open volumes. Cryptographic secrets are kept in memory for an undefined amount of time.</li>
<li><h4>Allow saving password hash using fingerprint:</h4> <li><b>Keep volumes open:</b>
Generate an AES-256 GCM key in the Android Keystore (protected by fingerprint authentication), then use it to encrypt the volume password hash and store it to the DroidFS internal storage. This require Android v6.0+. If your device is not encrypted, extracting the encryption key with physical access may be possible. (Different from the old <i>"Keep volumes open when the app goes in background"</i>. Yes it's confusing, sorry)
</li>
Keep the app running as a [foreground service](https://developer.android.com/develop/background-work/services/foreground-services) to maintain volumes open, even when the app is removed from recent tasks.
This avoid the app from being killed by the system during file operations or while accessing exposed volumes, but this mean cryptographic secrets stay in memory for an undefined amount of time.</li>
<li><b>Allow opening files with other applications⁽¹⁾:</b>
Decrypt and open file using external apps. These apps could save and send the files thus opened.</li>
<li><b>Expose open volumes⁽¹⁾:</b>
Allow open volumes to be browsed in the system file explorer (<a href="https://developer.android.com/guide/topics/providers/document-provider">DocumentProvider</a> API). Encrypted files can then be selected from other applications, potentially with permanent access. This feature requires <i>"Disable volume auto-locking"</i>, and works more reliably when <i>"Keep volumes open"</i> is also enabled.</li>
<li><b>Grant write access:</b>
Files opened with another applications can be modified by them. This applies to both previous unsafe features.</li>
</ul> </ul>
* Features requiring temporary writing of the plain file to disk (DroidFS internal storage). This file could be read by apps with root access or by physical access if your device is not encrypted.
⁽¹⁾: These features can work in two ways: temporarily writing the plain file to disk (DroidFS internal storage) or sharing it via memory. By default, DroidFS will choose to keep the file only in memory as it's more secure, but will fallback to disk export if the file is too large to be held in memory. This behavior can be changed with the *"Export method"* parameter in the settings. Please note that some applications require the file to be stored on disk, and therefore do not work with memory-exported files.
# Download # Download
<a href="https://f-droid.org/packages/sushi.hardcore.droidfs"> <a href="https://f-droid.org/packages/sushi.hardcore.droidfs">
<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75"> <img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">
</a> </a>
You can download DroidFS from [F-Droid](https://f-droid.org/packages/sushi.hardcore.droidfs) or from the "Releases" section in the repo. You can download DroidFS from [F-Droid](https://f-droid.org/packages/sushi.hardcore.droidfs) or from the "Releases" section in this repository.
APKs availables here are signed with my PGP key available on keyservers: APKs available here are signed with my PGP key available on keyservers:
`gpg --keyserver hkp://pool.sks-keyservers.net --recv-keys 007F84120107191E` \ `gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys AFE384344A45E13A` \
Fingerprint: `BD5621479E7B74D36A405BE8007F84120107191E` \ Fingerprint: `B64E FE86 CEE1 D054 F082 1711 AFE3 8434 4A45 E13A` \
Email: `Hardcore Sushi <hardcore.sushi@disroot.org>` Email: `Hardcore Sushi <hardcore.sushi@disroot.org>`
To verify APKs, save the PGP-signed message to a file and run `gpg --verify <the file>`. __Don't install any APK if the verification fails !__ To verify APKs, save the PGP-signed message to a file and run `gpg --verify <the file>`. __Don't install any APK if the verification fails !__
@ -65,80 +94,33 @@ __Don't install the APK if the checksums don't match!__
F-Droid APKs should be signed with the F-Droid key. More details [here](https://f-droid.org/docs/Release_Channels_and_Signing_Keys). F-Droid APKs should be signed with the F-Droid key. More details [here](https://f-droid.org/docs/Release_Channels_and_Signing_Keys).
# Permissions # Permissions
DroidFS need some permissions to work properly. Here is why: DroidFS needs some permissions for certain features. However, you are free to deny them if you do not wish to use these features.
<ul> - **Read & write access to shared storage**: Required to access volumes located on shared storage.
<li><h4>Read & write access to shared storage:</h4> - **Biometric/Fingerprint hardware**: Required to encrypt/decrypt password hashes using a fingerprint protected key.
Required for creating, opening and modifying volumes and for importing/exporting files to/from volumes. - **Camera**: Required to take encrypted photos or videos directly from the app.
</li> - **Record audio**: Required if you want sound on video recorded with DroidFS.
<li><h4>Biometric/Fingerprint hardware:</h4> - **Notifications**: Used to report file operations progress and notify about volumes kept open.
Required to encrypt/decrypt password hashes using a fingerprint protected key.
</li>
<li><h4>Camera:</h4>
Needed to take photos directly from DroidFS to import them securely. You can deny this permission if you don't want to use it.
</li>
</ul>
# Limitations # Limitations
DroidFS use some parts of the original gocryptfs code, which is designed to run on Linux x86 systems: it accesses the underlying file system with file paths and syscalls. However in Android, you can't access other apps files with file paths. Instead, you must use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. And obviously, the original gocryptfs code doesn't work with this API. This is why DroidFS can't open volumes provided by other applications, such as cloud storage clients. You can only use DroidFS with volumes located on shared storage or in the app's internal storage (hidden volumes). External storage such as SD cards are only supported in read-only access for now. DroidFS works as a wrapper around modified versions of the original encrypted container implementations ([libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) and [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs)). These programs were designed to run on standard x86 Linux systems: they access the underlying file system with file paths and syscalls. However, on Android, you can't access files from other applications using file paths. Instead, one has to use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. Obviously, neither Gocryptfs nor CryFS support this API. As a result, DroidFS cannot open volumes provided by other applications (such as cloud storage clients). If you want to synchronize your volumes on a cloud, the cloud application must synchronize the encrypted directory from disk.
# Build Due to Android's storage restrictions, encrypted volumes located on SD cards must be placed under `/Android/data/sushi.hardcore.droidfs/` if you want DroidFS to be able to modify them.
Most of the original gocryptfs code was used as is (written in Go) and compiled to native code. That's why you need [Go](https://golang.org) and the [Android Native Development Kit (NDK)](https://developer.android.com/ndk/) to build DroidFS from source.
# Building from source
#### Install Requirements You can follow the instructions in [BUILD.md](BUILD.md) to build DroidFS from source.
- [Android Studio](https://developer.android.com/studio/)
- [Android NDK and CMake](https://developer.android.com/studio/projects/install-ndk) (OpenSSL build fails with NDK versions higher than v22. It should pass with NDK v21.4.7075529 and lower)
- [Go](https://golang.org/doc/install) (on debian: `$ sudo apt-get install golang-go`)
#### Download Sources
```
$ git clone https://github.com/hardcore-sushi/DroidFS.git
```
Download [libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs):
```
$ cd DroidFS
$ git submodule update --init
```
libgocryptfs needs OpenSSL:
```
$ cd app/libgocryptfs
$ wget https://www.openssl.org/source/openssl-1.1.1l.tar.gz
```
Verify OpenSSL signature:
```
$ wget https://www.openssl.org/source/openssl-1.1.1l.tar.gz.asc
$ gpg --verify openssl-1.1.1l.tar.gz.asc openssl-1.1.1l.tar.gz
```
Continue **ONLY** if the signature is **VALID**.
```
$ tar -xvzf openssl-1.1.1l.tar.gz
```
#### Build
First, we need to install some dependencies:
```
$ sudo apt-get install libcrypto++-dev libssl-dev pkg-config
```
And also Go dependencies:
```
$ go get golang.org/x/sys/unix golang.org/x/sys/cpu golang.org/x/crypto/hkdf github.com/jacobsa/crypto/siv github.com/rfjakob/eme
```
Then, retrieve your Android NDK installation path, usually someting like "/home/\<user\>/Android/SDK/ndk/\<NDK version\>". We can now build libgocryptfs:
```
$ cd DroidFS/app/libgocryptfs
$ env ANDROID_NDK_HOME="<your ndk path>" OPENSSL_PATH="./openssl-1.1.1l" ./build.sh
```
Then, open the DroidFS project with Android Studio. \
If a device (virtual or physical) is connected, just click on "Run". \
If you want to generate a signed APK, you can follow this [post](https://stackoverflow.com/a/28938286).
# Third party code # Third party code
Thanks to these open source projects that DroidFS uses: Thanks to these open source projects that DroidFS uses:
### Modified code: ### Modified code:
- [gocryptfs](https://github.com/rfjakob/gocryptfs) to encrypt your data - Encrypted filesystems (to protect your data):
- [libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) (forked from [gocryptfs](https://github.com/rfjakob/gocryptfs))
- [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs) (forked from [CryFS](https://github.com/cryfs/cryfs))
- [libpdfviewer](https://forge.chapril.org/hardcoresushi/libpdfviewer) (forked from [PdfViewer](https://github.com/GrapheneOS/PdfViewer)) to open PDF files
- [DoubleTapPlayerView](https://github.com/vkay94/DoubleTapPlayerView) to add double-click controls to the video player
### Borrowed code:
- [MaterialFiles](https://github.com/zhanghai/MaterialFiles) for Kotlin natural sorting implementation
### Libraries: ### Libraries:
- [Cyanea](https://github.com/jaredrummler/Cyanea) to customize UI - [Glide](https://github.com/bumptech/glide) to display pictures
- [Glide](https://github.com/bumptech/glide/) to display pictures
- [ExoPlayer](https://github.com/google/ExoPlayer) to play media files - [ExoPlayer](https://github.com/google/ExoPlayer) to play media files

30
TODO.md Normal file
View File

@ -0,0 +1,30 @@
# TODO
Here's a list of features that it would be nice to have in DroidFS. As this is a FLOSS project, there are no special requirements on *when* or even *if* these features will be implemented, but contributions are greatly appreciated.
## Security
- [hardened_malloc](https://github.com/GrapheneOS/hardened_malloc) compatibility ([#181](https://github.com/hardcore-sushi/DroidFS/issues/181))
- Internal keyboard for passwords
## UX
- File associations editor
- Discovery before exporting
- Making discovery before file operations optional
- Modifiable scrypt parameters
- Alert dialog showing details of file operations
- Internal file browser to select volumes
## Encryption software support
- [Shufflecake](https://shufflecake.net): plausible deniability for multiple hidden filesystems on Linux (would be absolutely awesome to have but quite difficult)
- [fscrypt](https://www.kernel.org/doc/html/latest/filesystems/fscrypt.html): filesystem encryption at the kernel level
## Health
- Remove all android:configChanges from AndroidManifest.xml
- More efficient thumbnails cache
- Guide for translators
- Usage & code documentation
- Automated tests
## And:
- All the [feature requests on the GitHub repo](https://github.com/hardcore-sushi/DroidFS/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
- All the [feature requests on the Gitea repo](https://forge.chapril.org/hardcoresushi/DroidFS/issues?q=&state=open&labels=748)

View File

@ -1,28 +1,79 @@
cmake_minimum_required(VERSION 3.4.1) cmake_minimum_required(VERSION 3.10)
project(DroidFS)
option(GOCRYPTFS "build libgocryptfs" ON)
option(CRYFS "build libcryfs" ON)
add_library(memfile SHARED src/main/native/memfile.c)
target_link_libraries(memfile log)
if (GOCRYPTFS)
add_library(gocryptfs SHARED IMPORTED)
set_target_properties(
gocryptfs
PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI}/libgocryptfs.so
)
add_library(gocryptfs_jni SHARED src/main/native/gocryptfs_jni.c)
target_include_directories(gocryptfs_jni PRIVATE ${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI})
target_link_libraries(gocryptfs_jni gocryptfs)
endif()
if (CRYFS)
add_subdirectory(${PROJECT_SOURCE_DIR}/libcryfs)
add_library(cryfs_jni SHARED src/main/native/libcryfs.c)
target_link_libraries(cryfs_jni libcryfs-jni)
endif()
add_library( add_library(
gocryptfs avformat
SHARED SHARED
IMPORTED IMPORTED
) )
set_target_properties( set_target_properties(
gocryptfs avformat
PROPERTIES IMPORTED_LOCATION PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI}/libgocryptfs.so ${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI}/libavformat/libavformat.so
) )
add_library( add_library(
gocryptfs_jni avcodec
SHARED SHARED
src/main/native/gocryptfs_jni.c IMPORTED
) )
set_target_properties(
avcodec
PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI}/libavcodec/libavcodec.so
)
add_library(
avutil
SHARED
IMPORTED
)
set_target_properties(
avutil
PROPERTIES IMPORTED_LOCATION
${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI}/libavutil/libavutil.so
)
add_library(
mux
SHARED
src/main/native/libmux.c
)
target_include_directories(mux PRIVATE ${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI})
target_link_libraries( target_link_libraries(
gocryptfs_jni mux
gocryptfs avformat
) avcodec
avutil
include_directories( log
${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI}/ )
)

View File

@ -1,47 +1,94 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
if (hasProperty("disableCryFS")) {
ext.disableCryFS = getProperty("disableCryFS")
} else {
ext.disableCryFS = false
}
if (hasProperty("disableGocryptfs")) {
ext.disableGocryptfs = getProperty("disableGocryptfs")
} else {
ext.disableGocryptfs = false
}
android { android {
compileSdkVersion 30 compileSdk 34
buildToolsVersion "30.0.3" ndkVersion '25.2.9519653'
ndkVersion "21.4.7075529" namespace "sushi.hardcore.droidfs"
compileOptions { compileOptions {
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
def abiCodes = [ "x86": 1, "x86_64": 2, "armeabi-v7a": 3, "arm64-v8a": 4]
defaultConfig { defaultConfig {
applicationId "sushi.hardcore.droidfs" applicationId "sushi.hardcore.droidfs"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 29 targetSdkVersion 34
versionCode 18 versionCode 37
versionName "1.5.4" versionName "2.2.0"
ndk { splits {
abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a" abi {
enable true
reset() // fix unknown bug (https://ru.stackoverflow.com/questions/1557805/abis-armeabi-mips-mips64-riscv64-are-not-supported-for-platform)
if (project.hasProperty("abi")) {
include project.getProperty("abi")
} else {
abiCodes.keySet().each { abi -> include abi }
universalApk !project.hasProperty("nouniversal")
}
}
}
externalNativeBuild.cmake {
if (project.ext.disableGocryptfs) {
arguments "-DGOCRYPTFS=OFF"
}
if (project.ext.disableCryFS) {
arguments "-DCRYFS=OFF"
}
} }
} }
splits { applicationVariants.configureEach { variant ->
abi {
enable true
universalApk true
}
}
applicationVariants.all { variant ->
variant.resValue "string", "versionName", variant.versionName variant.resValue "string", "versionName", variant.versionName
buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}"
buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}"
variant.outputs.each { output ->
def abi = output.getFilter(com.android.build.OutputFile.ABI)
if (abi == null) { // universal
output.versionCodeOverride = variant.versionCode*10
output.outputFileName = "DroidFS-v${variant.versionName}-${variant.name}-universal.apk"
} else {
output.versionCodeOverride = variant.versionCode*10 + abiCodes[abi]
output.outputFileName = "DroidFS-v${variant.versionName}-${variant.name}-${abi}.apk"
}
}
} }
buildFeatures { buildFeatures {
viewBinding true viewBinding true
buildConfig true
} }
buildTypes { buildTypes {
release { release {
minifyEnabled true postprocessing {
shrinkResources true removeUnusedCode true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' removeUnusedResources true
obfuscate false
optimizeCode true
proguardFiles 'proguard-rules.pro'
}
} }
} }
@ -50,30 +97,45 @@ android {
path file('CMakeLists.txt') path file('CMakeLists.txt')
} }
} }
sourceSets {
main {
java {
exclude 'androidx/camera/video/originals/**'
}
}
}
} }
dependencies { dependencies {
implementation fileTree(dir: "libs", include: ["*.jar"]) implementation project(":libpdfviewer:app")
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation 'androidx.core:core-ktx:1.13.1'
implementation "androidx.core:core-ktx:1.6.0" implementation "androidx.appcompat:appcompat:1.7.0"
implementation "androidx.appcompat:appcompat:1.3.1" implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.constraintlayout:constraintlayout:2.1.0" def lifecycle_version = "2.8.3"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
implementation "androidx.sqlite:sqlite-ktx:2.1.0" implementation "androidx.preference:preference-ktx:1.2.1"
implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "com.jaredrummler:cyanea:1.0.2" implementation 'com.google.android.material:material:1.12.0'
implementation "com.github.bumptech.glide:glide:4.12.0" implementation 'com.github.bumptech.glide:glide:4.16.0'
implementation "androidx.biometric:biometric:1.1.0" implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
def exoplayer_version = "2.15.0" def media3_version = "1.3.1"
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version" implementation "androidx.media3:media3-exoplayer:$media3_version"
implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version" implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-datasource:$media3_version"
def camerax_v1 = "1.1.0-alpha08" def camerax_version = "1.3.4"
implementation "androidx.camera:camera-camera2:$camerax_v1" implementation "androidx.camera:camera-camera2:$camerax_version"
implementation "androidx.camera:camera-lifecycle:$camerax_v1" implementation "androidx.camera:camera-lifecycle:$camerax_version"
def camerax_v2 = "1.0.0-alpha28" implementation "androidx.camera:camera-view:$camerax_version"
implementation "androidx.camera:camera-view:$camerax_v2" implementation "androidx.camera:camera-extensions:$camerax_version"
implementation "androidx.camera:camera-extensions:$camerax_v2"
// dependencies needed by CameraX patch
implementation "androidx.concurrent:concurrent-futures:1.2.0"
def autoValueVersion = '1.10.4'
implementation "com.google.auto.value:auto-value-annotations:$autoValueVersion"
annotationProcessor "com.google.auto.value:auto-value:$autoValueVersion"
} }

1
app/ffmpeg/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
build

93
app/ffmpeg/build.sh Executable file
View File

@ -0,0 +1,93 @@
#!/bin/bash
set -e
if [ -z ${ANDROID_NDK_HOME+x} ]; then
echo "Error: \$ANDROID_NDK_HOME is not defined." >&2
exit 1
else
cd "$(dirname "$0")"
FFMPEG_DIR="ffmpeg"
compile_for_arch() {
echo "Compiling for $1..."
case $1 in
"x86_64")
CFN="x86_64-linux-android21-clang"
ARCH="x86_64"
;;
"x86")
CFN="i686-linux-android21-clang"
ARCH="i686"
EXTRA_FLAGS="--disable-asm"
;;
"arm64-v8a")
CFN="aarch64-linux-android21-clang"
ARCH="aarch64"
;;
"armeabi-v7a")
CFN="armv7a-linux-androideabi19-clang"
ARCH="arm"
;;
esac
(cd $FFMPEG_DIR
make clean || true
./configure \
--cc="$CFN" \
--cxx="$CFN++" \
--arch="$ARCH" \
$EXTRA_FLAGS \
--target-os=android \
--enable-cross-compile \
--enable-version3 \
--disable-programs \
--disable-static \
--enable-shared \
--disable-bsfs \
--disable-parsers \
--disable-demuxers \
--disable-muxers \
--enable-muxer="mp4" \
--disable-decoders \
--disable-encoders \
--enable-encoder="aac" \
--disable-avdevice \
--disable-swresample \
--disable-swscale \
--disable-postproc \
--disable-avfilter \
--disable-network \
--disable-doc \
--disable-htmlpages \
--disable-manpages \
--disable-podpages \
--disable-txtpages \
--disable-sndio \
--disable-schannel \
--disable-securetransport \
--disable-vulkan \
--disable-xlib \
--disable-zlib \
--disable-cuvid \
--disable-nvenc \
--disable-vdpau \
--disable-videotoolbox \
--disable-audiotoolbox \
--disable-appkit \
--disable-alsa \
--disable-debug
make -j "$(nproc --all)" >/dev/null)
mkdir -p "build/$1/libavformat" "build/$1/libavcodec" "build/$1/libavutil"
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so "build/$1/libavformat"
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so "build/$1/libavcodec"
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so "build/$1/libavutil"
}
export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
if [ $# -eq 1 ]; then
compile_for_arch "$1"
else
for abi in "x86_64" "x86" "arm64-v8a" "armeabi-v7a"; do
compile_for_arch $abi
done
fi
fi

1
app/ffmpeg/ffmpeg Submodule

@ -0,0 +1 @@
Subproject commit af25a4bfd2503caf3ee485b27b99b620302f5718

1
app/libcryfs Submodule

@ -0,0 +1 @@
Subproject commit cd0af7088066f870f12eceed9836bde897f1d164

@ -1 +1 @@
Subproject commit f0e45c7b7e428f13667edd3b8d73501e15322a04 Subproject commit b221d4cf3c70edc711169a769a476802d2577b2b

View File

@ -1,27 +1,18 @@
# Add project specific ProGuard rules here. -keepattributes SourceFile,LineNumberTable
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class sushi.hardcore.droidfs.SettingsActivity$** -keep class sushi.hardcore.droidfs.SettingsActivity$**
-keep class sushi.hardcore.droidfs.explorers.ExplorerElement -keep class sushi.hardcore.droidfs.explorers.ExplorerElement
-keepclassmembers class sushi.hardcore.droidfs.explorers.ExplorerElement { -keepclassmembers class sushi.hardcore.droidfs.explorers.ExplorerElement {
static sushi.hardcore.droidfs.explorers.ExplorerElement new(...); static sushi.hardcore.droidfs.explorers.ExplorerElement new(...);
} }
-keepclassmembers class sushi.hardcore.droidfs.video_recording.FFmpegMuxer {
void writePacket(byte[]);
void seek(long);
}
# Required for Intent.getParcelableExtra() to work on Android 13
-keep class sushi.hardcore.droidfs.VolumeData {
public int describeContents();
}
-keep class sushi.hardcore.droidfs.VolumeData$* {
static public android.os.Parcelable$Creator CREATOR;
}

View File

@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="sushi.hardcore.droidfs"
android:installLocation="auto"> android:installLocation="auto">
<permission <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
android:name="${applicationId}.WRITE_TEMPORARY_STORAGE" <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
android:protectionLevel="signature" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature <uses-feature
android:name="android.hardware.camera.any" android:name="android.hardware.camera.any"
@ -24,81 +24,66 @@
tools:node="remove" /> <!--removing this permission automatically added by exoplayer--> tools:node="remove" /> <!--removing this permission automatically added by exoplayer-->
<application <application
android:name=".ColoredApplication" android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:icon="@mipmap/icon_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/BaseTheme"
<activity android:name=".VolumeManagerApp"
android:name=".CameraActivity" android:fullBackupContent="false"
android:screenOrientation="nosensor" /> android:dataExtractionRules="@xml/backup_rules">
<activity <activity android:name=".MainActivity" android:exported="true">
android:name=".SettingsActivity" <intent-filter>
android:label="@string/title_activity_settings" <action android:name="android.intent.action.MAIN" />
android:parentActivityName=".MainActivity" /> <category android:name="android.intent.category.LAUNCHER" />
<activity android:name=".explorers.ExplorerActivity" /> </intent-filter>
<activity android:name=".explorers.ExplorerActivityPick" />
<activity android:name=".explorers.ExplorerActivityDrop" />
<activity
android:name=".OpenActivity"
android:parentActivityName=".MainActivity"
android:screenOrientation="nosensor"
android:windowSoftInputMode="adjustPan">
<intent-filter android:label="@string/share_menu_label"> <intent-filter android:label="@string/share_menu_label">
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" /> <action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" /> <data android:mimeType="*/*" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity android:name=".SettingsActivity" android:label="@string/title_activity_settings"/>
android:name=".CreateActivity" <activity android:name=".add_volume.AddVolumeActivity" android:windowSoftInputMode="adjustResize"/>
android:parentActivityName=".MainActivity" <activity android:name=".ChangePasswordActivity" android:windowSoftInputMode="adjustResize"/>
android:screenOrientation="nosensor" /> <activity android:name=".explorers.ExplorerActivity"/>
<activity <activity android:name=".explorers.ExplorerActivityPick"/>
android:name=".ChangePasswordActivity" <activity android:name=".explorers.ExplorerActivityDrop"/>
android:parentActivityName=".MainActivity" <activity android:name=".file_viewers.ImageViewer"/>
android:screenOrientation="nosensor" <activity android:name=".file_viewers.VideoPlayer" android:configChanges="screenSize|orientation" />
android:windowSoftInputMode="adjustPan" /> <activity android:name=".file_viewers.PdfViewer" android:configChanges="screenSize|orientation" android:theme="@style/AppTheme" />
<activity <activity android:name=".file_viewers.AudioPlayer" android:configChanges="screenSize|orientation" />
android:name=".MainActivity" <activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" />
android:screenOrientation="nosensor"> <activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
<intent-filter> <activity android:name=".LogcatActivity"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <service android:name=".KeepAliveService" android:exported="false" android:foregroundServiceType="dataSync" />
</intent-filter> <service android:name=".ClosingService" android:exported="false" android:stopWithTask="false"/>
</activity> <service android:name=".file_operations.FileOperationService" android:exported="false" android:foregroundServiceType="dataSync"/>
<activity
android:name=".file_viewers.ImageViewer"
android:configChanges="screenSize|orientation" /> <!-- don't reload content on configuration change -->
<activity
android:name=".file_viewers.VideoPlayer"
android:configChanges="screenSize|orientation" />
<activity
android:name=".file_viewers.AudioPlayer"
android:configChanges="screenSize|orientation" />
<activity
android:name=".file_viewers.TextEditor"
android:configChanges="screenSize|orientation" />
<service android:name=".file_operations.FileOperationService" android:exported="false"/> <receiver android:name=".NotificationBroadcastReceiver" android:exported="false">
<receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false">
<intent-filter> <intent-filter>
<action android:name="file_operation_cancel"/> <action android:name="file_operation_cancel"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<provider <provider
android:name=".content_providers.RestrictedFileProvider" android:name=".content_providers.TemporaryFileProvider"
android:authorities="${applicationId}.temporary_provider" android:authorities="${applicationId}.temporary_provider"
android:exported="true"/>
<provider
android:authorities="${applicationId}.volume_provider"
android:name=".content_providers.VolumeProvider"
android:exported="true" android:exported="true"
android:writePermission="${applicationId}.WRITE_TEMPORARY_STORAGE" /> android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application> </application>
</manifest> </manifest>

View File

@ -0,0 +1,14 @@
package androidx.camera.video
import android.media.MediaCodec
import android.media.MediaFormat
import java.nio.ByteBuffer
interface MediaMuxer {
fun setOrientationHint(degree: Int)
fun release()
fun addTrack(mediaFormat: MediaFormat): Int
fun start()
fun writeSampleData(trackIndex: Int, buffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo)
fun stop()
}

View File

@ -0,0 +1,16 @@
package androidx.camera.video
import android.location.Location
class MuxerOutputOptions(private val mediaMuxer: MediaMuxer): OutputOptions(MuxerOutputOptionsInternal()) {
private class MuxerOutputOptionsInternal: OutputOptionsInternal() {
override fun getFileSizeLimit(): Long = FILE_SIZE_UNLIMITED.toLong()
override fun getDurationLimitMillis(): Long = DURATION_UNLIMITED.toLong()
override fun getLocation(): Location? = null
}
fun getMediaMuxer(): MediaMuxer = mediaMuxer
}

View File

@ -0,0 +1,254 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.camera.video;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.CheckResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RequiresPermission;
import androidx.camera.core.impl.utils.ContextUtil;
import androidx.core.content.PermissionChecker;
import androidx.core.util.Consumer;
import androidx.core.util.Preconditions;
import java.util.concurrent.Executor;
/**
* A recording that can be started at a future time.
*
* <p>A pending recording allows for configuration of a recording before it is started. Once a
* pending recording is started with {@link #start(Executor, Consumer)}, any changes to the pending
* recording will not affect the actual recording; any modifications to the recording will need
* to occur through the controls of the {@link SucklessRecording} class returned by
* {@link #start(Executor, Consumer)}.
*
* <p>A pending recording can be created using one of the {@link Recorder} methods for starting a
* recording such as {@link Recorder#prepareRecording(Context, MediaStoreOutputOptions)}.
* <p>There may be more settings that can only be changed per-recorder instead of per-recording,
* because it requires expensive operations like reconfiguring the camera. For those settings, use
* the {@link Recorder.Builder} methods to configure before creating the {@link Recorder}
* instance, then create the pending recording with it.
*/
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
@SuppressLint("RestrictedApi")
public final class SucklessPendingRecording {
private final Context mContext;
private final SucklessRecorder mRecorder;
private final OutputOptions mOutputOptions;
private Consumer<VideoRecordEvent> mEventListener;
private Executor mListenerExecutor;
private boolean mAudioEnabled = false;
private boolean mIsPersistent = false;
SucklessPendingRecording(@NonNull Context context, @NonNull SucklessRecorder recorder,
@NonNull OutputOptions options) {
// Application context is sufficient for all our needs, so store that to avoid leaking
// unused resources. For attribution, ContextUtil.getApplicationContext() will retain the
// attribution tag from the original context.
mContext = ContextUtil.getApplicationContext(context);
mRecorder = recorder;
mOutputOptions = options;
}
/**
* Returns an application context which was retrieved from the {@link Context} used to
* create this object.
*/
@NonNull
Context getApplicationContext() {
return mContext;
}
@NonNull
SucklessRecorder getRecorder() {
return mRecorder;
}
@NonNull
OutputOptions getOutputOptions() {
return mOutputOptions;
}
@Nullable
Executor getListenerExecutor() {
return mListenerExecutor;
}
@Nullable
Consumer<VideoRecordEvent> getEventListener() {
return mEventListener;
}
boolean isAudioEnabled() {
return mAudioEnabled;
}
boolean isPersistent() {
return mIsPersistent;
}
/**
* Enables audio to be recorded for this recording.
*
* <p>This method must be called prior to {@link #start(Executor, Consumer)} to enable audio
* in the recording. If this method is not called, the {@link SucklessRecording} generated by
* {@link #start(Executor, Consumer)} will not contain audio, and
* {@link AudioStats#getAudioState()} will always return
* {@link AudioStats#AUDIO_STATE_DISABLED} for all {@link RecordingStats} send to the listener
* set passed to {@link #start(Executor, Consumer)}.
*
* <p>Recording with audio requires the {@link android.Manifest.permission#RECORD_AUDIO}
* permission; without it, recording will fail at {@link #start(Executor, Consumer)} with an
* {@link IllegalStateException}.
*
* @return this pending recording
* @throws IllegalStateException if the {@link Recorder} this recording is associated to
* doesn't support audio.
* @throws SecurityException if the {@link Manifest.permission#RECORD_AUDIO} permission
* is denied for the current application.
*/
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
@NonNull
public SucklessPendingRecording withAudioEnabled() {
// Check permissions and throw a security exception if RECORD_AUDIO is not granted.
if (PermissionChecker.checkSelfPermission(mContext, Manifest.permission.RECORD_AUDIO)
== PermissionChecker.PERMISSION_DENIED) {
throw new SecurityException("Attempted to enable audio for recording but application "
+ "does not have RECORD_AUDIO permission granted.");
}
Preconditions.checkState(mRecorder.isAudioSupported(), "The Recorder this recording is "
+ "associated to doesn't support audio.");
mAudioEnabled = true;
return this;
}
/**
* Configures the recording to be a persistent recording.
*
* <p>A persistent recording will only be stopped by explicitly calling
* {@link Recording#stop()} or {@link Recording#close()} and will ignore events that would
* normally cause recording to stop, such as lifecycle events or explicit unbinding of a
* {@link VideoCapture} use case that the recording's {@link Recorder} is attached to.
*
* <p>Even though lifecycle events or explicit unbinding use cases won't stop a persistent
* recording, it will still stop the camera from producing data, resulting in the in-progress
* persistent recording stopping getting data until the camera stream is activated again. For
* example, when the activity goes into background, the recording will keep waiting for new
* data to be recorded until the activity is back to foreground.
*
* <p>A {@link Recorder} instance is recommended to be associated with a single
* {@link VideoCapture} instance, especially when using persistent recording. Otherwise, there
* might be unexpected behavior. Any in-progress persistent recording created from the same
* {@link Recorder} should be stopped before starting a new recording, even if the
* {@link Recorder} is associated with a different {@link VideoCapture}.
*
* <p>To switch to a different camera stream while a recording is in progress, first create
* the recording as persistent recording, then rebind the {@link VideoCapture} it's
* associated with to a different camera. The implementation may be like:
* <pre>{@code
* // Prepare the Recorder and VideoCapture, then bind the VideoCapture to the back camera.
* Recorder recorder = Recorder.Builder().build();
* VideoCapture videoCapture = VideoCapture.withOutput(recorder);
* cameraProvider.bindToLifecycle(
* lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, videoCapture);
*
* // Prepare the persistent recording and start it.
* Recording recording = recorder
* .prepareRecording(context, outputOptions)
* .asPersistentRecording()
* .start(eventExecutor, eventListener);
*
* // Record from the back camera for a period of time.
*
* // Rebind the VideoCapture to the front camera.
* cameraProvider.unbindAll();
* cameraProvider.bindToLifecycle(
* lifecycleOwner, CameraSelector.DEFAULT_FRONT_CAMERA, videoCapture);
*
* // Record from the front camera for a period of time.
*
* // Stop the recording explicitly.
* recording.stop();
* }</pre>
*
* <p>The audio data will still be recorded after the {@link VideoCapture} is unbound.
* {@link Recording#pause() Pause} the recording first and {@link Recording#resume() resume} it
* later to stop recording audio while rebinding use cases.
*
* <p>If the recording is unable to receive data from the new camera, possibly because of
* incompatible surface combination, an exception will be thrown when binding to lifecycle.
*/
@ExperimentalPersistentRecording
@NonNull
public SucklessPendingRecording asPersistentRecording() {
mIsPersistent = true;
return this;
}
/**
* Starts the recording, making it an active recording.
*
* <p>Only a single recording can be active at a time, so if another recording is active,
* this will throw an {@link IllegalStateException}.
*
* <p>If there are no errors starting the recording, the returned {@link SucklessRecording}
* can be used to {@link SucklessRecording#pause() pause}, {@link SucklessRecording#resume() resume},
* or {@link SucklessRecording#stop() stop} the recording.
*
* <p>Upon successfully starting the recording, a {@link VideoRecordEvent.Start} event will
* be the first event sent to the provided event listener.
*
* <p>If errors occur while starting the recording, a {@link VideoRecordEvent.Finalize} event
* will be the first event sent to the provided listener, and information about the error can
* be found in that event's {@link VideoRecordEvent.Finalize#getError()} method. The returned
* {@link SucklessRecording} will be in a finalized state, and all controls will be no-ops.
*
* <p>If the returned {@link SucklessRecording} is garbage collected, the recording will be
* automatically stopped. A reference to the active recording must be maintained as long as
* the recording needs to be active. If the recording is garbage collected, the
* {@link VideoRecordEvent.Finalize} event will contain error
* {@link VideoRecordEvent.Finalize#ERROR_RECORDING_GARBAGE_COLLECTED}.
*
* <p>The {@link Recording} will be stopped automatically if the {@link VideoCapture} its
* {@link Recorder} is attached to is unbound unless it's created
* {@link #asPersistentRecording() as a persistent recording}.
*
* @throws IllegalStateException if the associated Recorder currently has an unfinished
* active recording.
* @param listenerExecutor the executor that the event listener will be run on.
* @param listener the event listener to handle video record events.
*/
@NonNull
@CheckResult
public SucklessRecording start(
@NonNull Executor listenerExecutor,
@NonNull Consumer<VideoRecordEvent> listener) {
Preconditions.checkNotNull(listenerExecutor, "Listener Executor can't be null.");
Preconditions.checkNotNull(listener, "Event listener can't be null");
mListenerExecutor = listenerExecutor;
mEventListener = listener;
return mRecorder.start(this);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,263 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.camera.video;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import android.annotation.SuppressLint;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.camera.core.impl.utils.CloseGuardHelper;
import androidx.core.util.Consumer;
import androidx.core.util.Preconditions;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Provides controls for the currently active recording.
*
* <p>An active recording is created by starting a pending recording with
* {@link PendingRecording#start(Executor, Consumer)}. If there are no errors starting the
* recording, upon creation, an active recording will provide controls to pause, resume or stop a
* recording. If errors occur while starting the recording, the active recording will be
* instantiated in a {@link VideoRecordEvent.Finalize finalized} state, and all controls will be
* no-ops. The state of the recording can be observed by the video record event listener provided
* to {@link PendingRecording#start(Executor, Consumer)} when starting the recording.
*
* <p>Either {@link #stop()} or {@link #close()} can be called when it is desired to
* stop the recording. If {@link #stop()} or {@link #close()} are not called on this object
* before it is no longer referenced, it will be automatically stopped at a future point in time
* when the object is garbage collected, and no new recordings can be started from the same
* {@link Recorder} that generated the object until that occurs.
*/
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
@SuppressLint("RestrictedApi")
public final class SucklessRecording implements AutoCloseable {
// Indicates the recording has been explicitly stopped by users.
private final AtomicBoolean mIsClosed = new AtomicBoolean(false);
private final SucklessRecorder mRecorder;
private final long mRecordingId;
private final OutputOptions mOutputOptions;
private final boolean mIsPersistent;
private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
SucklessRecording(@NonNull SucklessRecorder recorder, long recordingId, @NonNull OutputOptions options,
boolean isPersistent, boolean finalizedOnCreation) {
mRecorder = recorder;
mRecordingId = recordingId;
mOutputOptions = options;
mIsPersistent = isPersistent;
if (finalizedOnCreation) {
mIsClosed.set(true);
} else {
mCloseGuard.open("stop");
}
}
/**
* Creates an {@link SucklessRecording} from a {@link PendingRecording} and recording ID.
*
* <p>The recording ID is expected to be unique to the recorder that generated the pending
* recording.
*/
@NonNull
static SucklessRecording from(@NonNull SucklessPendingRecording pendingRecording, long recordingId) {
Preconditions.checkNotNull(pendingRecording, "The given PendingRecording cannot be null.");
return new SucklessRecording(pendingRecording.getRecorder(),
recordingId,
pendingRecording.getOutputOptions(),
pendingRecording.isPersistent(),
/*finalizedOnCreation=*/false);
}
/**
* Creates an {@link SucklessRecording} from a {@link PendingRecording} and recording ID in a
* finalized state.
*
* <p>This can be used if there was an error setting up the active recording and it would not
* be able to be started.
*
* <p>The recording ID is expected to be unique to the recorder that generated the pending
* recording.
*/
@NonNull
static SucklessRecording createFinalizedFrom(@NonNull SucklessPendingRecording pendingRecording,
long recordingId) {
Preconditions.checkNotNull(pendingRecording, "The given PendingRecording cannot be null.");
return new SucklessRecording(pendingRecording.getRecorder(),
recordingId,
pendingRecording.getOutputOptions(),
pendingRecording.isPersistent(),
/*finalizedOnCreation=*/true);
}
@NonNull
OutputOptions getOutputOptions() {
return mOutputOptions;
}
/**
* Returns whether this recording is a persistent recording.
*
* <p>A persistent recording will only be stopped by explicitly calling of
* {@link Recording#stop()} and will ignore the lifecycle events or source state changes.
* Users are responsible of stopping a persistent recording.
*
* @return {@code true} if the recording is a persistent recording, otherwise {@code false}.
*/
@ExperimentalPersistentRecording
public boolean isPersistent() {
return mIsPersistent;
}
/**
* Pauses the current recording if active.
*
* <p>Successful pausing of a recording will generate a {@link VideoRecordEvent.Pause} event
* which will be sent to the listener passed to
* {@link PendingRecording#start(Executor, Consumer)}.
*
* <p>If the recording has already been paused or has been finalized internally, this is a
* no-op.
*
* @throws IllegalStateException if the recording has been stopped with
* {@link #close()} or {@link #stop()}.
*/
public void pause() {
if (mIsClosed.get()) {
throw new IllegalStateException("The recording has been stopped.");
}
mRecorder.pause(this);
}
/**
* Resumes the current recording if paused.
*
* <p>Successful resuming of a recording will generate a {@link VideoRecordEvent.Resume} event
* which will be sent to the listener passed to
* {@link PendingRecording#start(Executor, Consumer)}.
*
* <p>If the recording is active or has been finalized internally, this is a no-op.
*
* @throws IllegalStateException if the recording has been stopped with
* {@link #close()} or {@link #stop()}.
*/
public void resume() {
if (mIsClosed.get()) {
throw new IllegalStateException("The recording has been stopped.");
}
mRecorder.resume(this);
}
/**
* Stops the recording, as if calling {@link #close()}.
*
* <p>This method is equivalent to calling {@link #close()}.
*/
public void stop() {
close();
}
/**
* Mutes or un-mutes the current recording.
*
* <p>The output file will contain an audio track even the whole recording is muted. Create a
* recording without calling {@link PendingRecording#withAudioEnabled()} to record a file
* with no audio track.
*
* <p>Muting or unmuting a recording that isn't created
* {@link PendingRecording#withAudioEnabled()} with audio enabled is no-op.
*
* @param muted mutes the recording if {@code true}, un-mutes otherwise.
*/
public void mute(boolean muted) {
if (mIsClosed.get()) {
throw new IllegalStateException("The recording has been stopped.");
}
mRecorder.mute(this, muted);
}
/**
* Close this recording.
*
* <p>Once {@link #stop()} or {@code close()} called, all methods for controlling the state of
* this recording besides {@link #stop()} or {@code close()} will throw an
* {@link IllegalStateException}.
*
* <p>Once an active recording has been closed, the next recording can be started with
* {@link PendingRecording#start(Executor, Consumer)}.
*
* <p>This method is idempotent; if the recording has already been closed or has been
* finalized internally, calling {@link #stop()} or {@code close()} is a no-op.
*
* <p>This method is invoked automatically on active recording instances managed by the {@code
* try-with-resources} statement.
*/
@Override
public void close() {
stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null);
}
@Override
@SuppressWarnings("GenericException") // super.finalize() throws Throwable
protected void finalize() throws Throwable {
try {
mCloseGuard.warnIfOpen();
stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED,
new RuntimeException("Recording stopped due to being garbage collected."));
} finally {
super.finalize();
}
}
/** Returns the recording ID which is unique to the recorder that generated this recording. */
long getRecordingId() {
return mRecordingId;
}
/**
* Returns whether the recording is closed.
*
* <p>The returned value does not reflect the state of the recording; it only reflects
* whether {@link #stop()} or {@link #close()} was called on this object.
*
* <p>The state of the recording should be checked from the listener passed to
* {@link PendingRecording#start(Executor, Consumer)}. Once the active recording is
* stopped, a {@link VideoRecordEvent.Finalize} event will be sent to the listener.
*
*/
@RestrictTo(LIBRARY_GROUP)
public boolean isClosed() {
return mIsClosed.get();
}
private void stopWithError(@VideoRecordEvent.Finalize.VideoRecordError int error,
@Nullable Throwable errorCause) {
mCloseGuard.close();
if (mIsClosed.getAndSet(true)) {
return;
}
mRecorder.stop(this, error, errorCause);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
# Update the modified CameraX files to a new upstream version:
Create the `new` folder if needed:
```
mkdir -p new
```
Put the new CameraX files from upstream (`androidx.camera.video.Recorder`, `androidx.camera.video.Recording`, `androidx.camera.video.PendingRecording` and `androidx.camera.video.internal.encoder.EncoderImpl`) in the `new` folder.
Perform the 3 way merge:
```
./merge.sh
```
If new files are created in the current directory, they contains conflicts. Resolve them then move them to the right location.
Finally, update the base:
```
./update.sh
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,252 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.camera.video;
import android.Manifest;
import android.content.Context;
import androidx.annotation.CheckResult;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RequiresPermission;
import androidx.camera.core.impl.utils.ContextUtil;
import androidx.core.content.PermissionChecker;
import androidx.core.util.Consumer;
import androidx.core.util.Preconditions;
import java.util.concurrent.Executor;
/**
* A recording that can be started at a future time.
*
* <p>A pending recording allows for configuration of a recording before it is started. Once a
* pending recording is started with {@link #start(Executor, Consumer)}, any changes to the pending
* recording will not affect the actual recording; any modifications to the recording will need
* to occur through the controls of the {@link Recording} class returned by
* {@link #start(Executor, Consumer)}.
*
* <p>A pending recording can be created using one of the {@link Recorder} methods for starting a
* recording such as {@link Recorder#prepareRecording(Context, MediaStoreOutputOptions)}.
* <p>There may be more settings that can only be changed per-recorder instead of per-recording,
* because it requires expensive operations like reconfiguring the camera. For those settings, use
* the {@link Recorder.Builder} methods to configure before creating the {@link Recorder}
* instance, then create the pending recording with it.
*/
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
public final class PendingRecording {
private final Context mContext;
private final Recorder mRecorder;
private final OutputOptions mOutputOptions;
private Consumer<VideoRecordEvent> mEventListener;
private Executor mListenerExecutor;
private boolean mAudioEnabled = false;
private boolean mIsPersistent = false;
PendingRecording(@NonNull Context context, @NonNull Recorder recorder,
@NonNull OutputOptions options) {
// Application context is sufficient for all our needs, so store that to avoid leaking
// unused resources. For attribution, ContextUtil.getApplicationContext() will retain the
// attribution tag from the original context.
mContext = ContextUtil.getApplicationContext(context);
mRecorder = recorder;
mOutputOptions = options;
}
/**
* Returns an application context which was retrieved from the {@link Context} used to
* create this object.
*/
@NonNull
Context getApplicationContext() {
return mContext;
}
@NonNull
Recorder getRecorder() {
return mRecorder;
}
@NonNull
OutputOptions getOutputOptions() {
return mOutputOptions;
}
@Nullable
Executor getListenerExecutor() {
return mListenerExecutor;
}
@Nullable
Consumer<VideoRecordEvent> getEventListener() {
return mEventListener;
}
boolean isAudioEnabled() {
return mAudioEnabled;
}
boolean isPersistent() {
return mIsPersistent;
}
/**
* Enables audio to be recorded for this recording.
*
* <p>This method must be called prior to {@link #start(Executor, Consumer)} to enable audio
* in the recording. If this method is not called, the {@link Recording} generated by
* {@link #start(Executor, Consumer)} will not contain audio, and
* {@link AudioStats#getAudioState()} will always return
* {@link AudioStats#AUDIO_STATE_DISABLED} for all {@link RecordingStats} send to the listener
* set passed to {@link #start(Executor, Consumer)}.
*
* <p>Recording with audio requires the {@link android.Manifest.permission#RECORD_AUDIO}
* permission; without it, recording will fail at {@link #start(Executor, Consumer)} with an
* {@link IllegalStateException}.
*
* @return this pending recording
* @throws IllegalStateException if the {@link Recorder} this recording is associated to
* doesn't support audio.
* @throws SecurityException if the {@link Manifest.permission#RECORD_AUDIO} permission
* is denied for the current application.
*/
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
@NonNull
public PendingRecording withAudioEnabled() {
// Check permissions and throw a security exception if RECORD_AUDIO is not granted.
if (PermissionChecker.checkSelfPermission(mContext, Manifest.permission.RECORD_AUDIO)
== PermissionChecker.PERMISSION_DENIED) {
throw new SecurityException("Attempted to enable audio for recording but application "
+ "does not have RECORD_AUDIO permission granted.");
}
Preconditions.checkState(mRecorder.isAudioSupported(), "The Recorder this recording is "
+ "associated to doesn't support audio.");
mAudioEnabled = true;
return this;
}
/**
* Configures the recording to be a persistent recording.
*
* <p>A persistent recording will only be stopped by explicitly calling
* {@link Recording#stop()} or {@link Recording#close()} and will ignore events that would
* normally cause recording to stop, such as lifecycle events or explicit unbinding of a
* {@link VideoCapture} use case that the recording's {@link Recorder} is attached to.
*
* <p>Even though lifecycle events or explicit unbinding use cases won't stop a persistent
* recording, it will still stop the camera from producing data, resulting in the in-progress
* persistent recording stopping getting data until the camera stream is activated again. For
* example, when the activity goes into background, the recording will keep waiting for new
* data to be recorded until the activity is back to foreground.
*
* <p>A {@link Recorder} instance is recommended to be associated with a single
* {@link VideoCapture} instance, especially when using persistent recording. Otherwise, there
* might be unexpected behavior. Any in-progress persistent recording created from the same
* {@link Recorder} should be stopped before starting a new recording, even if the
* {@link Recorder} is associated with a different {@link VideoCapture}.
*
* <p>To switch to a different camera stream while a recording is in progress, first create
* the recording as persistent recording, then rebind the {@link VideoCapture} it's
* associated with to a different camera. The implementation may be like:
* <pre>{@code
* // Prepare the Recorder and VideoCapture, then bind the VideoCapture to the back camera.
* Recorder recorder = Recorder.Builder().build();
* VideoCapture videoCapture = VideoCapture.withOutput(recorder);
* cameraProvider.bindToLifecycle(
* lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, videoCapture);
*
* // Prepare the persistent recording and start it.
* Recording recording = recorder
* .prepareRecording(context, outputOptions)
* .asPersistentRecording()
* .start(eventExecutor, eventListener);
*
* // Record from the back camera for a period of time.
*
* // Rebind the VideoCapture to the front camera.
* cameraProvider.unbindAll();
* cameraProvider.bindToLifecycle(
* lifecycleOwner, CameraSelector.DEFAULT_FRONT_CAMERA, videoCapture);
*
* // Record from the front camera for a period of time.
*
* // Stop the recording explicitly.
* recording.stop();
* }</pre>
*
* <p>The audio data will still be recorded after the {@link VideoCapture} is unbound.
* {@link Recording#pause() Pause} the recording first and {@link Recording#resume() resume} it
* later to stop recording audio while rebinding use cases.
*
* <p>If the recording is unable to receive data from the new camera, possibly because of
* incompatible surface combination, an exception will be thrown when binding to lifecycle.
*/
@ExperimentalPersistentRecording
@NonNull
public PendingRecording asPersistentRecording() {
mIsPersistent = true;
return this;
}
/**
* Starts the recording, making it an active recording.
*
* <p>Only a single recording can be active at a time, so if another recording is active,
* this will throw an {@link IllegalStateException}.
*
* <p>If there are no errors starting the recording, the returned {@link Recording}
* can be used to {@link Recording#pause() pause}, {@link Recording#resume() resume},
* or {@link Recording#stop() stop} the recording.
*
* <p>Upon successfully starting the recording, a {@link VideoRecordEvent.Start} event will
* be the first event sent to the provided event listener.
*
* <p>If errors occur while starting the recording, a {@link VideoRecordEvent.Finalize} event
* will be the first event sent to the provided listener, and information about the error can
* be found in that event's {@link VideoRecordEvent.Finalize#getError()} method. The returned
* {@link Recording} will be in a finalized state, and all controls will be no-ops.
*
* <p>If the returned {@link Recording} is garbage collected, the recording will be
* automatically stopped. A reference to the active recording must be maintained as long as
* the recording needs to be active. If the recording is garbage collected, the
* {@link VideoRecordEvent.Finalize} event will contain error
* {@link VideoRecordEvent.Finalize#ERROR_RECORDING_GARBAGE_COLLECTED}.
*
* <p>The {@link Recording} will be stopped automatically if the {@link VideoCapture} its
* {@link Recorder} is attached to is unbound unless it's created
* {@link #asPersistentRecording() as a persistent recording}.
*
* @throws IllegalStateException if the associated Recorder currently has an unfinished
* active recording.
* @param listenerExecutor the executor that the event listener will be run on.
* @param listener the event listener to handle video record events.
*/
@NonNull
@CheckResult
public Recording start(
@NonNull Executor listenerExecutor,
@NonNull Consumer<VideoRecordEvent> listener) {
Preconditions.checkNotNull(listenerExecutor, "Listener Executor can't be null.");
Preconditions.checkNotNull(listener, "Event listener can't be null");
mListenerExecutor = listenerExecutor;
mEventListener = listener;
return mRecorder.start(this);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,260 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.camera.video;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.camera.core.impl.utils.CloseGuardHelper;
import androidx.core.util.Consumer;
import androidx.core.util.Preconditions;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Provides controls for the currently active recording.
*
* <p>An active recording is created by starting a pending recording with
* {@link PendingRecording#start(Executor, Consumer)}. If there are no errors starting the
* recording, upon creation, an active recording will provide controls to pause, resume or stop a
* recording. If errors occur while starting the recording, the active recording will be
* instantiated in a {@link VideoRecordEvent.Finalize finalized} state, and all controls will be
* no-ops. The state of the recording can be observed by the video record event listener provided
* to {@link PendingRecording#start(Executor, Consumer)} when starting the recording.
*
* <p>Either {@link #stop()} or {@link #close()} can be called when it is desired to
* stop the recording. If {@link #stop()} or {@link #close()} are not called on this object
* before it is no longer referenced, it will be automatically stopped at a future point in time
* when the object is garbage collected, and no new recordings can be started from the same
* {@link Recorder} that generated the object until that occurs.
*/
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
public final class Recording implements AutoCloseable {
// Indicates the recording has been explicitly stopped by users.
private final AtomicBoolean mIsClosed = new AtomicBoolean(false);
private final Recorder mRecorder;
private final long mRecordingId;
private final OutputOptions mOutputOptions;
private final boolean mIsPersistent;
private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
Recording(@NonNull Recorder recorder, long recordingId, @NonNull OutputOptions options,
boolean isPersistent, boolean finalizedOnCreation) {
mRecorder = recorder;
mRecordingId = recordingId;
mOutputOptions = options;
mIsPersistent = isPersistent;
if (finalizedOnCreation) {
mIsClosed.set(true);
} else {
mCloseGuard.open("stop");
}
}
/**
* Creates an {@link Recording} from a {@link PendingRecording} and recording ID.
*
* <p>The recording ID is expected to be unique to the recorder that generated the pending
* recording.
*/
@NonNull
static Recording from(@NonNull PendingRecording pendingRecording, long recordingId) {
Preconditions.checkNotNull(pendingRecording, "The given PendingRecording cannot be null.");
return new Recording(pendingRecording.getRecorder(),
recordingId,
pendingRecording.getOutputOptions(),
pendingRecording.isPersistent(),
/*finalizedOnCreation=*/false);
}
/**
* Creates an {@link Recording} from a {@link PendingRecording} and recording ID in a
* finalized state.
*
* <p>This can be used if there was an error setting up the active recording and it would not
* be able to be started.
*
* <p>The recording ID is expected to be unique to the recorder that generated the pending
* recording.
*/
@NonNull
static Recording createFinalizedFrom(@NonNull PendingRecording pendingRecording,
long recordingId) {
Preconditions.checkNotNull(pendingRecording, "The given PendingRecording cannot be null.");
return new Recording(pendingRecording.getRecorder(),
recordingId,
pendingRecording.getOutputOptions(),
pendingRecording.isPersistent(),
/*finalizedOnCreation=*/true);
}
@NonNull
OutputOptions getOutputOptions() {
return mOutputOptions;
}
/**
* Returns whether this recording is a persistent recording.
*
* <p>A persistent recording will only be stopped by explicitly calling of
* {@link Recording#stop()} and will ignore the lifecycle events or source state changes.
* Users are responsible of stopping a persistent recording.
*
* @return {@code true} if the recording is a persistent recording, otherwise {@code false}.
*/
@ExperimentalPersistentRecording
public boolean isPersistent() {
return mIsPersistent;
}
/**
* Pauses the current recording if active.
*
* <p>Successful pausing of a recording will generate a {@link VideoRecordEvent.Pause} event
* which will be sent to the listener passed to
* {@link PendingRecording#start(Executor, Consumer)}.
*
* <p>If the recording has already been paused or has been finalized internally, this is a
* no-op.
*
* @throws IllegalStateException if the recording has been stopped with
* {@link #close()} or {@link #stop()}.
*/
public void pause() {
if (mIsClosed.get()) {
throw new IllegalStateException("The recording has been stopped.");
}
mRecorder.pause(this);
}
/**
* Resumes the current recording if paused.
*
* <p>Successful resuming of a recording will generate a {@link VideoRecordEvent.Resume} event
* which will be sent to the listener passed to
* {@link PendingRecording#start(Executor, Consumer)}.
*
* <p>If the recording is active or has been finalized internally, this is a no-op.
*
* @throws IllegalStateException if the recording has been stopped with
* {@link #close()} or {@link #stop()}.
*/
public void resume() {
if (mIsClosed.get()) {
throw new IllegalStateException("The recording has been stopped.");
}
mRecorder.resume(this);
}
/**
* Stops the recording, as if calling {@link #close()}.
*
* <p>This method is equivalent to calling {@link #close()}.
*/
public void stop() {
close();
}
/**
* Mutes or un-mutes the current recording.
*
* <p>The output file will contain an audio track even the whole recording is muted. Create a
* recording without calling {@link PendingRecording#withAudioEnabled()} to record a file
* with no audio track.
*
* <p>Muting or unmuting a recording that isn't created
* {@link PendingRecording#withAudioEnabled()} with audio enabled is no-op.
*
* @param muted mutes the recording if {@code true}, un-mutes otherwise.
*/
public void mute(boolean muted) {
if (mIsClosed.get()) {
throw new IllegalStateException("The recording has been stopped.");
}
mRecorder.mute(this, muted);
}
/**
* Close this recording.
*
* <p>Once {@link #stop()} or {@code close()} called, all methods for controlling the state of
* this recording besides {@link #stop()} or {@code close()} will throw an
* {@link IllegalStateException}.
*
* <p>Once an active recording has been closed, the next recording can be started with
* {@link PendingRecording#start(Executor, Consumer)}.
*
* <p>This method is idempotent; if the recording has already been closed or has been
* finalized internally, calling {@link #stop()} or {@code close()} is a no-op.
*
* <p>This method is invoked automatically on active recording instances managed by the {@code
* try-with-resources} statement.
*/
@Override
public void close() {
stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null);
}
@Override
@SuppressWarnings("GenericException") // super.finalize() throws Throwable
protected void finalize() throws Throwable {
try {
mCloseGuard.warnIfOpen();
stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED,
new RuntimeException("Recording stopped due to being garbage collected."));
} finally {
super.finalize();
}
}
/** Returns the recording ID which is unique to the recorder that generated this recording. */
long getRecordingId() {
return mRecordingId;
}
/**
* Returns whether the recording is closed.
*
* <p>The returned value does not reflect the state of the recording; it only reflects
* whether {@link #stop()} or {@link #close()} was called on this object.
*
* <p>The state of the recording should be checked from the listener passed to
* {@link PendingRecording#start(Executor, Consumer)}. Once the active recording is
* stopped, a {@link VideoRecordEvent.Finalize} event will be sent to the listener.
*
*/
@RestrictTo(LIBRARY_GROUP)
public boolean isClosed() {
return mIsClosed.get();
}
private void stopWithError(@VideoRecordEvent.Finalize.VideoRecordError int error,
@Nullable Throwable errorCause) {
mCloseGuard.close();
if (mIsClosed.getAndSet(true)) {
return;
}
mRecorder.stop(this, error, errorCause);
}
}

View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
for i in "PendingRecording" "Recording" "Recorder"; do
diff3 -m ../Suckless$i.java base/$i.java new/$i.java > Suckless$i.java && mv Suckless$i.java ..
done
diff3 -m ../internal/encoder/SucklessEncoderImpl.java base/EncoderImpl.java new/EncoderImpl.java > SucklessEncoderImpl.java && mv SucklessEncoderImpl.java ../internal/encoder/SucklessEncoderImpl.java

View File

@ -0,0 +1,3 @@
#!/bin/sh
rm -r base && mv new base

View File

@ -3,36 +3,23 @@ package sushi.hardcore.droidfs
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.core.content.ContextCompat import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.jaredrummler.cyanea.app.CyaneaAppCompatActivity
import sushi.hardcore.droidfs.widgets.ThemeColor
open class BaseActivity: CyaneaAppCompatActivity() { open class BaseActivity: AppCompatActivity() {
protected lateinit var sharedPrefs: SharedPreferences protected lateinit var sharedPrefs: SharedPreferences
protected var isRecreating = false protected var applyCustomTheme: Boolean = true
lateinit var theme: Theme
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val themeColor = ThemeColor.getThemeColor(this)
if (cyanea.accent != themeColor){
changeThemeColor(themeColor)
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this) sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
if (!sharedPrefs.getBoolean("usf_screenshot", false)){ theme = Theme.fromSharedPrefs(sharedPrefs)
if (applyCustomTheme) {
setTheme(theme.toResourceId())
}
if (!sharedPrefs.getBoolean("usf_screenshot", false)) {
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
} }
} }
fun changeThemeColor(themeColor: Int? = null){
val accentColor = themeColor ?: ThemeColor.getThemeColor(this)
val backgroundColor = ContextCompat.getColor(this, R.color.backgroundColor)
isRecreating = true
cyanea.edit{
accent(accentColor)
//accentDark(themeColor)
//accentLight(themeColor)
background(backgroundColor)
//backgroundDark(backgroundColor)
//backgroundLight(backgroundColor)
}.recreate(this)
}
} }

View File

@ -1,45 +1,72 @@
package sushi.hardcore.droidfs package sushi.hardcore.droidfs
import android.Manifest import android.Manifest
import android.content.Context import android.annotation.SuppressLint
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.InputType import android.text.InputType
import android.util.Size import android.util.Size
import android.view.MotionEvent import android.view.MotionEvent
import android.view.ScaleGestureDetector import android.view.ScaleGestureDetector
import android.view.Surface
import android.view.View import android.view.View
import android.view.WindowManager
import android.view.animation.Animation import android.view.animation.Animation
import android.view.animation.LinearInterpolator import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation import android.view.animation.RotateAnimation
import android.widget.EditText
import android.widget.ImageView import android.widget.ImageView
import android.widget.RelativeLayout import android.widget.RelativeLayout
import android.widget.Toast import android.widget.Toast
import androidx.camera.camera2.interop.Camera2CameraInfo import androidx.annotation.RequiresApi
import androidx.camera.core.* import androidx.camera.core.AspectRatio
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.DynamicRange
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.core.UseCase
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.extensions.ExtensionMode import androidx.camera.extensions.ExtensionMode
import androidx.camera.extensions.ExtensionsManager import androidx.camera.extensions.ExtensionsManager
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.MuxerOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.SucklessRecorder
import androidx.camera.video.SucklessRecording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import sushi.hardcore.droidfs.adapters.DialogSingleChoiceAdapter import androidx.lifecycle.lifecycleScope
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.databinding.ActivityCameraBinding import sushi.hardcore.droidfs.databinding.ActivityCameraBinding
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.util.finishOnClose
import sushi.hardcore.droidfs.video_recording.AsynchronousSeekableWriter
import sushi.hardcore.droidfs.video_recording.FFmpegMuxer
import sushi.hardcore.droidfs.video_recording.SeekableWriter
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Date
import java.util.Locale
import java.util.Random
import java.util.concurrent.Executor import java.util.concurrent.Executor
import kotlin.math.pow
import kotlin.math.sqrt
@SuppressLint("RestrictedApi")
class CameraActivity : BaseActivity(), SensorOrientationListener.Listener { class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
companion object { companion object {
private const val CAMERA_PERMISSION_REQUEST_CODE = 1 private const val CAMERA_PERMISSION_REQUEST_CODE = 0
private const val AUDIO_PERMISSION_REQUEST_CODE = 1
private const val fileNameRandomMin = 100000 private const val fileNameRandomMin = 100000
private const val fileNameRandomMax = 999999 private const val fileNameRandomMax = 999999
private val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) private val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
@ -55,33 +82,46 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
binding.imageTimer.setImageResource(R.drawable.icon_timer_off) binding.imageTimer.setImageResource(R.drawable.icon_timer_off)
} }
} }
private var usf_keep_open = false
private lateinit var sensorOrientationListener: SensorOrientationListener private lateinit var sensorOrientationListener: SensorOrientationListener
private var currentRotation = 0
private var previousOrientation: Float = 0f private var previousOrientation: Float = 0f
private lateinit var orientedIcons: List<ImageView> private lateinit var orientedIcons: List<ImageView>
private lateinit var gocryptfsVolume: GocryptfsVolume private lateinit var encryptedVolume: EncryptedVolume
private lateinit var outputDirectory: String private lateinit var outputDirectory: String
private lateinit var fileName: String
private var isFinishingIntentionally = false
private var permissionsGranted = false private var permissionsGranted = false
private lateinit var executor: Executor private lateinit var executor: Executor
private lateinit var cameraProvider: ProcessCameraProvider private lateinit var cameraProvider: ProcessCameraProvider
private lateinit var extensionsManager: ExtensionsManager private lateinit var extensionsManager: ExtensionsManager
private lateinit var cameraSelector: CameraSelector
private val cameraPreview = Preview.Builder().build() private val cameraPreview = Preview.Builder().build()
private var imageCapture: ImageCapture? = null private var imageCapture: ImageCapture? = null
private var videoCapture: VideoCapture<SucklessRecorder>? = null
private var videoRecorder: SucklessRecorder? = null
private var videoRecording: SucklessRecording? = null
private var camera: Camera? = null private var camera: Camera? = null
private var resolutions: List<Size>? = null private var resolutions: List<Size>? = null
private var currentResolutionIndex: Int = 0 private var currentResolutionIndex: Int = 0
private var currentResolution: Size? = null
private val aspectRatios = arrayOf(AspectRatio.RATIO_16_9, AspectRatio.RATIO_4_3)
private var currentAspectRatioIndex = 0
private var qualities: List<Quality>? = null
private var currentQualityIndex = -1
private var captureMode = ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY private var captureMode = ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
private var isBackCamera = true private var isBackCamera = true
private var isInVideoMode = false
private var isRecording = false
private var isWaitingForTimer = false
private lateinit var binding: ActivityCameraBinding private lateinit var binding: ActivityCameraBinding
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false)
binding = ActivityCameraBinding.inflate(layoutInflater) binding = ActivityCameraBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
gocryptfsVolume = GocryptfsVolume(intent.getIntExtra("sessionID", -1)) supportActionBar?.hide()
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
intent.getIntExtra("volumeId", -1)
)!!
finishOnClose(encryptedVolume)
outputDirectory = intent.getStringExtra("path")!! outputDirectory = intent.getStringExtra("path")!!
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@ -99,99 +139,152 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
ProcessCameraProvider.getInstance(this).apply { ProcessCameraProvider.getInstance(this).apply {
addListener({ addListener({
cameraProvider = get() cameraProvider = get()
setupCamera() ExtensionsManager.getInstanceAsync(this@CameraActivity, cameraProvider).apply {
}, executor) addListener({
} extensionsManager = get()
ExtensionsManager.getInstance(this).apply { setupCamera()
addListener({ }, executor)
extensionsManager = get() }
setupCamera()
}, executor) }, executor)
} }
binding.imageCaptureMode.setOnClickListener { binding.imageCaptureMode.setOnClickListener {
val currentIndex = if (captureMode == ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) { if (isInVideoMode) {
0 qualities?.let { qualities ->
} else { val qualityNames = qualities.map {
1 when (it) {
} Quality.UHD -> "UHD"
ColoredAlertDialogBuilder(this) Quality.FHD -> "FHD"
.setTitle(R.string.camera_optimization) Quality.HD -> "HD"
.setSingleChoiceItems(DialogSingleChoiceAdapter(this, arrayOf(R.string.maximize_quality, R.string.minimize_latency).map { getString(it) }), currentIndex) { dialog, which -> Quality.SD -> "SD"
val resId: Int else -> throw IllegalArgumentException("Invalid quality: $it")
val newCaptureMode = if (which == 0) { }
resId = R.drawable.icon_high_quality }.toTypedArray()
ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY CustomAlertDialogBuilder(this, theme)
} else { .setTitle("Choose quality:")
resId = R.drawable.icon_speed .setSingleChoiceItems(qualityNames, currentQualityIndex) { dialog, which ->
ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY currentQualityIndex = which
} rebindUseCases()
if (newCaptureMode != captureMode) { dialog.dismiss()
captureMode = newCaptureMode }
binding.imageCaptureMode.setImageResource(resId) .setNegativeButton(R.string.cancel, null)
setupCamera() .show()
}
dialog.dismiss()
} }
.setNegativeButton(R.string.cancel, null) } else {
.show() CustomAlertDialogBuilder(this, theme)
} .setTitle(R.string.camera_optimization)
binding.imageRatio.setOnClickListener { .setSingleChoiceItems(
resolutions?.let { arrayOf(getString(R.string.maximize_quality), getString(R.string.minimize_latency)),
ColoredAlertDialogBuilder(this) if (captureMode == ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) 0 else 1
.setTitle(R.string.choose_resolution) ) { dialog, which ->
.setSingleChoiceItems(DialogSingleChoiceAdapter(this, it.map { size -> size.toString() }), currentResolutionIndex) { dialog, which -> val newCaptureMode = if (which == 0) {
setupCamera(resolutions!![which]) ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
} else {
ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
}
if (newCaptureMode != captureMode) {
captureMode = newCaptureMode
setCaptureModeIcon()
rebindUseCases()
}
dialog.dismiss() dialog.dismiss()
currentResolutionIndex = which
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
} }
binding.imageTimer.setOnClickListener { binding.imageRatio.setOnClickListener {
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null) if (isInVideoMode) {
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text) CustomAlertDialogBuilder(this, theme)
dialogEditText.inputType = InputType.TYPE_CLASS_NUMBER .setTitle("Aspect ratio:")
val dialog = ColoredAlertDialogBuilder(this) .setSingleChoiceItems(arrayOf("16:9", "4:3"), currentAspectRatioIndex) { dialog, which ->
.setView(dialogEditTextView) currentAspectRatioIndex = which
.setTitle(getString(R.string.enter_timer_duration)) rebindUseCases()
.setPositiveButton(R.string.ok) { _, _ -> dialog.dismiss()
val enteredValue = dialogEditText.text.toString()
if (enteredValue.isEmpty()){
Toast.makeText(this, getString(R.string.timer_empty_error_msg), Toast.LENGTH_SHORT).show()
} else {
timerDuration = enteredValue.toInt()
} }
.setNegativeButton(R.string.cancel, null)
.show()
} else {
resolutions?.let {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.choose_resolution)
.setSingleChoiceItems(it.map { size -> size.toString() }.toTypedArray(), currentResolutionIndex) { dialog, which ->
currentResolution = resolutions!![which]
currentResolutionIndex = which
rebindUseCases()
dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
.show()
} }
.setNegativeButton(R.string.cancel, null)
.create()
dialogEditText.setOnEditorActionListener { _, _, _ ->
timerDuration = dialogEditText.text.toString().toInt()
dialog.dismiss()
true
} }
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
dialog.show()
} }
binding.imageClose.setOnClickListener { binding.imageTimer.setOnClickListener {
isFinishingIntentionally = true with (EditTextDialog(this, R.string.enter_timer_duration) {
finish() try {
timerDuration = it.toInt()
} catch (e: NumberFormatException) {
Toast.makeText(this, R.string.invalid_number, Toast.LENGTH_SHORT).show()
}
}) {
binding.dialogEditText.inputType = InputType.TYPE_CLASS_NUMBER
show()
}
} }
binding.imageFlash.setOnClickListener { binding.imageFlash.setOnClickListener {
binding.imageFlash.setImageResource(when (imageCapture?.flashMode) { binding.imageFlash.setImageResource(if (isInVideoMode) {
ImageCapture.FLASH_MODE_AUTO -> { when (imageCapture?.flashMode) {
imageCapture?.flashMode = ImageCapture.FLASH_MODE_ON ImageCapture.FLASH_MODE_ON -> {
R.drawable.icon_flash_on camera?.cameraControl?.enableTorch(false)
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
R.drawable.icon_flash_off
}
else -> {
camera?.cameraControl?.enableTorch(true)
imageCapture?.flashMode = ImageCapture.FLASH_MODE_ON
R.drawable.icon_flash_on
}
} }
ImageCapture.FLASH_MODE_ON -> { } else {
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF when (imageCapture?.flashMode) {
R.drawable.icon_flash_off ImageCapture.FLASH_MODE_AUTO -> {
imageCapture?.flashMode = ImageCapture.FLASH_MODE_ON
R.drawable.icon_flash_on
}
ImageCapture.FLASH_MODE_ON -> {
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
R.drawable.icon_flash_off
}
else -> {
imageCapture?.flashMode = ImageCapture.FLASH_MODE_AUTO
R.drawable.icon_flash_auto
}
} }
else -> { })
imageCapture?.flashMode = ImageCapture.FLASH_MODE_AUTO }
R.drawable.icon_flash_auto binding.imageModeSwitch.setOnClickListener {
isInVideoMode = !isInVideoMode
rebindUseCases()
binding.imageFlash.setImageResource(if (isInVideoMode) {
binding.recordVideoButton.visibility = View.VISIBLE
binding.takePhotoButton.visibility = View.GONE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), AUDIO_PERMISSION_REQUEST_CODE)
}
} }
binding.imageModeSwitch.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.icon_photo)?.mutate()?.also {
it.setTint(ContextCompat.getColor(this, R.color.neutralIconTint))
})
setCaptureModeIcon()
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
R.drawable.icon_flash_off
} else {
binding.recordVideoButton.visibility = View.GONE
binding.takePhotoButton.visibility = View.VISIBLE
binding.imageModeSwitch.setImageResource(R.drawable.icon_video)
imageCapture?.flashMode = ImageCapture.FLASH_MODE_AUTO
R.drawable.icon_flash_auto
}) })
} }
binding.imageCameraSwitch.setOnClickListener { binding.imageCameraSwitch.setOnClickListener {
@ -200,12 +293,20 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
false false
} else { } else {
binding.imageCameraSwitch.setImageResource(R.drawable.icon_camera_front) binding.imageCameraSwitch.setImageResource(R.drawable.icon_camera_front)
if (isInVideoMode) {
//reset flash state
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
binding.imageFlash.setImageResource(R.drawable.icon_flash_off)
}
true true
} }
resolutions = null
qualities = null
setupCamera() setupCamera()
} }
binding.takePhotoButton.onClick = ::onClickTakePhoto binding.takePhotoButton.onClick = ::onClickTakePhoto
orientedIcons = listOf(binding.imageRatio, binding.imageTimer, binding.imageClose, binding.imageFlash, binding.imageCameraSwitch) binding.recordVideoButton.setOnClickListener { onClickRecordVideo() }
orientedIcons = listOf(binding.imageRatio, binding.imageTimer, binding.imageCaptureMode, binding.imageFlash, binding.imageModeSwitch, binding.imageCameraSwitch)
sensorOrientationListener = SensorOrientationListener(this) sensorOrientationListener = SensorOrientationListener(this)
val scaleGestureDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.SimpleOnScaleGestureListener(){ val scaleGestureDetector = ScaleGestureDetector(this, object : ScaleGestureDetector.SimpleOnScaleGestureListener(){
@ -232,147 +333,254 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
} }
} }
@RequiresApi(Build.VERSION_CODES.M)
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) { if (grantResults.size == 1) {
CAMERA_PERMISSION_REQUEST_CODE -> if (grantResults.size == 1) { when (requestCode) {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { CAMERA_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
permissionsGranted = true permissionsGranted = true
setupCamera() setupCamera()
} else { } else {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.camera_perm_needed) .setMessage(R.string.camera_perm_needed)
.setCancelable(false) .setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ -> finish() }.show()
isFinishingIntentionally = true }
finish() AUDIO_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
}.show() if (videoCapture != null) {
cameraProvider.unbind(videoCapture)
camera = cameraProvider.bindToLifecycle(this, cameraSelector, videoCapture)
}
} }
} }
} }
} }
private fun setCaptureModeIcon() {
binding.imageCaptureMode.setImageResource(if (isInVideoMode) {
R.drawable.icon_high_quality
} else {
if (captureMode == ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) {
R.drawable.icon_speed
} else {
R.drawable.icon_high_quality
}
})
}
private fun adaptPreviewSize(resolution: Size) { private fun adaptPreviewSize(resolution: Size) {
val screenWidth = resources.displayMetrics.widthPixels val screenWidth = resources.displayMetrics.widthPixels
binding.cameraPreview.layoutParams = if (screenWidth < resolution.width) { val screenHeight = resources.displayMetrics.heightPixels
RelativeLayout.LayoutParams(
screenWidth, var height = (resolution.height * (screenWidth.toFloat() / resolution.width)).toInt()
(resolution.height * (screenWidth.toFloat() / resolution.width)).toInt() var width = screenWidth
) if (height > screenHeight) {
} else { width = (width * (screenHeight.toFloat() / height)).toInt()
RelativeLayout.LayoutParams(resolution.width, resolution.height) height = screenHeight
}
binding.cameraPreview.layoutParams = RelativeLayout.LayoutParams(width, height).apply {
addRule(RelativeLayout.CENTER_IN_PARENT)
} }
(binding.cameraPreview.layoutParams as RelativeLayout.LayoutParams).addRule(RelativeLayout.CENTER_IN_PARENT)
} }
private fun setupCamera(resolution: Size? = null){ private fun refreshImageCapture() {
if (permissionsGranted && ::extensionsManager.isInitialized && ::cameraProvider.isInitialized) { imageCapture = ImageCapture.Builder()
imageCapture = ImageCapture.Builder() .setCaptureMode(captureMode)
.setCaptureMode(captureMode) .setFlashMode(imageCapture?.flashMode ?: ImageCapture.FLASH_MODE_AUTO)
.setFlashMode(imageCapture?.flashMode ?: ImageCapture.FLASH_MODE_AUTO) .setResolutionSelector(ResolutionSelector.Builder().setResolutionFilter { supportedSizes, _ ->
.apply { resolutions = supportedSizes.sortedBy {
resolution?.let { -it.width*it.height
setTargetResolution(it) }
currentResolution?.let { targetResolution ->
return@setResolutionFilter supportedSizes.sortedBy {
sqrt((it.width - targetResolution.width).toDouble().pow(2) + (it.height - targetResolution.height).toDouble().pow(2))
} }
} }
.build() supportedSizes
}.build())
.setTargetRotation(currentRotation)
.build()
}
var cameraSelector = if (isBackCamera){ CameraSelector.DEFAULT_BACK_CAMERA } else { CameraSelector.DEFAULT_FRONT_CAMERA } private fun refreshVideoCapture() {
if (extensionsManager.isExtensionAvailable(cameraProvider, cameraSelector, ExtensionMode.HDR)) { val recorderBuilder = SucklessRecorder.Builder()
cameraSelector = extensionsManager.getExtensionEnabledCameraSelector(cameraProvider, cameraSelector, ExtensionMode.HDR) .setExecutor(executor)
} .setAspectRatio(aspectRatios[currentAspectRatioIndex])
if (currentQualityIndex != -1) {
cameraProvider.unbindAll() recorderBuilder.setQualitySelector(QualitySelector.from(qualities!![currentQualityIndex]))
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture) }
videoRecorder = recorderBuilder.build()
adaptPreviewSize(resolution ?: imageCapture!!.attachedSurfaceResolution!!.swap()) videoCapture = VideoCapture.withOutput(videoRecorder!!).apply {
targetRotation = currentRotation
if (resolutions == null) {
val info = Camera2CameraInfo.from(camera!!.cameraInfo)
val cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
val characteristics = cameraManager.getCameraCharacteristics(info.cameraId)
characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)?.let { streamConfigurationMap ->
resolutions = streamConfigurationMap.getOutputSizes(imageCapture!!.imageFormat).map { it.swap() }
}
}
} }
} }
private fun takePhoto() { private fun rebindUseCases(): UseCase {
val imageCapture = imageCapture ?: return cameraProvider.unbindAll()
val outputBuff = ByteArrayOutputStream() val currentUseCase = (if (isInVideoMode) {
val outputOptions = ImageCapture.OutputFileOptions.Builder(outputBuff).build() refreshVideoCapture()
imageCapture.takePicture(outputOptions, executor, object : ImageCapture.OnImageSavedCallback { camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture)
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { if (qualities == null) {
binding.takePhotoButton.onPhotoTaken() qualities = SucklessRecorder.getVideoCapabilities(camera!!.cameraInfo).getSupportedQualities(DynamicRange.UNSPECIFIED)
if (gocryptfsVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), PathUtils.pathJoin(outputDirectory, fileName))){ }
Toast.makeText(applicationContext, getString(R.string.picture_save_success, fileName), Toast.LENGTH_SHORT).show() videoCapture
} else { } else {
ColoredAlertDialogBuilder(this@CameraActivity) refreshImageCapture()
.setTitle(R.string.error) camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture)
.setMessage(R.string.picture_save_failed) imageCapture
.setCancelable(false) })!!
.setPositiveButton(R.string.ok) { _, _ -> adaptPreviewSize(currentUseCase.attachedSurfaceResolution!!.swap())
isFinishingIntentionally = true return currentUseCase
finish() }
}
.show() private fun setupCamera() {
if (permissionsGranted && ::extensionsManager.isInitialized && ::cameraProvider.isInitialized) {
cameraSelector = if (isBackCamera){ CameraSelector.DEFAULT_BACK_CAMERA } else { CameraSelector.DEFAULT_FRONT_CAMERA }
if (extensionsManager.isExtensionAvailable(cameraSelector, ExtensionMode.AUTO)) {
cameraSelector = extensionsManager.getExtensionEnabledCameraSelector(cameraSelector, ExtensionMode.AUTO)
}
rebindUseCases()
}
}
private fun getOutputPath(isVideo: Boolean): String {
val baseName = if (isVideo) {"VID"} else {"IMG"}+'_'+dateFormat.format(Date())+'_'
var outputPath: String
do {
val fileName = baseName+(random.nextInt(fileNameRandomMax-fileNameRandomMin)+fileNameRandomMin)+'.'+ if (isVideo) {"mp4"} else {"jpg"}
outputPath = PathUtils.pathJoin(outputDirectory, fileName)
} while (encryptedVolume.pathExists(outputPath))
return outputPath
}
private fun startTimerThen(action: () -> Unit) {
if (timerDuration > 0){
binding.textTimer.visibility = View.VISIBLE
isWaitingForTimer = true
lifecycleScope.launch {
for (i in timerDuration downTo 1){
binding.textTimer.text = i.toString()
delay(1000)
} }
if (!isFinishing) {
action()
binding.textTimer.visibility = View.GONE
}
isWaitingForTimer = false
} }
override fun onError(exception: ImageCaptureException) { } else {
binding.takePhotoButton.onPhotoTaken() action()
Toast.makeText(applicationContext, exception.message, Toast.LENGTH_SHORT).show() }
}
})
} }
private fun onClickTakePhoto() { private fun onClickTakePhoto() {
val baseName = "IMG_"+dateFormat.format(Date())+"_" if (!isWaitingForTimer) {
do { val outputPath = getOutputPath(false)
fileName = baseName+(random.nextInt(fileNameRandomMax-fileNameRandomMin)+fileNameRandomMin)+".jpg" startTimerThen {
} while (gocryptfsVolume.pathExists(fileName)) imageCapture?.let { imageCapture ->
if (timerDuration > 0){ val outputBuff = ByteArrayOutputStream()
binding.textTimer.visibility = View.VISIBLE val outputOptions = ImageCapture.OutputFileOptions.Builder(outputBuff).build()
Thread{ imageCapture.takePicture(outputOptions, executor, object : ImageCapture.OnImageSavedCallback {
for (i in timerDuration downTo 1){ override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
runOnUiThread { binding.textTimer.text = i.toString() } binding.takePhotoButton.onPhotoTaken()
Thread.sleep(1000) if (encryptedVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), outputPath)) {
Toast.makeText(applicationContext, getString(R.string.picture_save_success, outputPath), Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@CameraActivity, theme)
.setTitle(R.string.error)
.setMessage(R.string.picture_save_failed)
.setPositiveButton(R.string.ok, null)
.show()
}
}
override fun onError(exception: ImageCaptureException) {
binding.takePhotoButton.onPhotoTaken()
Toast.makeText(applicationContext, exception.message, Toast.LENGTH_SHORT).show()
}
})
} }
runOnUiThread { }
takePhoto()
binding.textTimer.visibility = View.GONE
}
}.start()
} else {
takePhoto()
} }
} }
override fun onDestroy() { @SuppressLint("MissingPermission")
super.onDestroy() private fun onClickRecordVideo() {
if (!isFinishingIntentionally) { if (isRecording) {
gocryptfsVolume.close() videoRecording?.stop()
RestrictedFileProvider.wipeAll(this) } else if (!isWaitingForTimer) {
} val path = getOutputPath(true)
} val fileHandle = encryptedVolume.openFileWriteMode(path)
if (fileHandle == -1L) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.file_creation_failed)
.setPositiveButton(R.string.ok, null)
.show()
return
}
val writer = AsynchronousSeekableWriter(object : SeekableWriter {
private var offset = 0L
override fun onStop() { override fun close() {
super.onStop() encryptedVolume.closeFile(fileHandle)
if (!isFinishing && !usf_keep_open){ }
finish()
override fun seek(offset: Long) {
this.offset = offset
}
override fun write(buffer: ByteArray, size: Int) {
offset += encryptedVolume.write(fileHandle, offset, buffer, 0, size.toLong())
}
})
val pendingRecording = videoRecorder!!.prepareRecording(
this,
MuxerOutputOptions(FFmpegMuxer(writer))
).also {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
it.withAudioEnabled()
}
}
startTimerThen {
writer.start()
videoRecording = pendingRecording.start(executor) {
val buttons = arrayOf(binding.imageCaptureMode, binding.imageRatio, binding.imageTimer, binding.imageModeSwitch, binding.imageCameraSwitch)
when (it) {
is VideoRecordEvent.Start -> {
binding.recordVideoButton.setImageResource(R.drawable.stop_recording_video_button)
for (i in buttons) {
i.isEnabled = false
i.alpha = 0.5F
}
isRecording = true
}
is VideoRecordEvent.Finalize -> {
if (it.hasError()) {
it.cause?.printStackTrace()
Toast.makeText(applicationContext, it.cause?.message ?: ("Error: " + it.error), Toast.LENGTH_SHORT).show()
videoRecording?.close()
videoRecording = null
} else {
Toast.makeText(applicationContext, getString(R.string.video_save_success, path), Toast.LENGTH_SHORT).show()
}
binding.recordVideoButton.setImageResource(R.drawable.record_video_button)
for (i in buttons) {
i.isEnabled = true
i.alpha = 1F
}
isRecording = false
}
}
}
}
} }
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
sensorOrientationListener.remove(this) sensorOrientationListener.remove(this)
if (
(ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) //not asking for permission
&& !usf_keep_open
){
finish()
}
} }
override fun onResume() { override fun onResume() {
@ -380,27 +588,26 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
sensorOrientationListener.addListener(this) sensorOrientationListener.addListener(this)
} }
override fun onBackPressed() {
super.onBackPressed()
isFinishingIntentionally = true
}
override fun onOrientationChange(newOrientation: Int) { override fun onOrientationChange(newOrientation: Int) {
val reversedOrientation = when (newOrientation){ val realOrientation = when (newOrientation) {
90 -> 270 Surface.ROTATION_0 -> 0f
270 -> 90 Surface.ROTATION_90 -> 90f
else -> newOrientation Surface.ROTATION_180 -> 180f
}.toFloat() else -> 270f
}
val rotateAnimation = RotateAnimation(previousOrientation, when { val rotateAnimation = RotateAnimation(previousOrientation, when {
reversedOrientation - previousOrientation > 180 -> reversedOrientation - 360 realOrientation - previousOrientation > 180 -> realOrientation - 360
reversedOrientation - previousOrientation < -180 -> reversedOrientation + 360 realOrientation - previousOrientation < -180 -> realOrientation + 360
else -> reversedOrientation else -> realOrientation
}, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f) }, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f)
rotateAnimation.duration = 300 rotateAnimation.duration = 300
rotateAnimation.interpolator = LinearInterpolator() rotateAnimation.interpolator = LinearInterpolator()
rotateAnimation.fillAfter = true rotateAnimation.fillAfter = true
orientedIcons.map { it.startAnimation(rotateAnimation) } orientedIcons.map { it.startAnimation(rotateAnimation) }
previousOrientation = reversedOrientation previousOrientation = realOrientation
imageCapture?.targetRotation = newOrientation
videoCapture?.targetRotation = newOrientation
currentRotation = newOrientation
} }
} }

View File

@ -1,221 +1,217 @@
package sushi.hardcore.droidfs package sushi.hardcore.droidfs
import android.net.Uri import android.annotation.SuppressLint
import android.content.Context
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.InputType
import android.text.TextWatcher import android.view.MenuItem
import android.widget.AdapterView.OnItemClickListener import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope
import sushi.hardcore.droidfs.adapters.SavedVolumesAdapter
import sushi.hardcore.droidfs.databinding.ActivityChangePasswordBinding import sushi.hardcore.droidfs.databinding.ActivityChangePasswordBinding
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.filesystems.CryfsVolume
import sushi.hardcore.droidfs.util.WidgetUtil import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Wiper import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.util.IntentUtils
import java.io.File import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.util.* import java.util.*
class ChangePasswordActivity : VolumeActionActivity() { class ChangePasswordActivity: BaseActivity() {
private lateinit var savedVolumesAdapter: SavedVolumesAdapter
private lateinit var binding: ActivityChangePasswordBinding private lateinit var binding: ActivityChangePasswordBinding
private lateinit var volume: VolumeData
private lateinit var volumeDatabase: VolumeDatabase
private var fingerprintProtector: FingerprintProtector? = null
private var usfFingerprint: Boolean = false
private val inputMethodManager by lazy {
getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
volume = IntentUtils.getParcelableExtra(intent, "volume")!!
binding = ActivityChangePasswordBinding.inflate(layoutInflater) binding = ActivityChangePasswordBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setupLayout() title = getString(R.string.change_password)
setupFingerprintStuff() supportActionBar?.setDisplayHomeAsUpEnabled(true)
savedVolumesAdapter = SavedVolumesAdapter(this, volumeDatabase) binding.textVolumeName.text = volume.name
if (savedVolumesAdapter.count > 0){ volumeDatabase = VolumeDatabase(this)
binding.savedPathListview.adapter = savedVolumesAdapter usfFingerprint = sharedPrefs.getBoolean("usf_fingerprint", false)
binding.savedPathListview.onItemClickListener = OnItemClickListener { _, _, position, _ -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val volume = savedVolumesAdapter.getItem(position) fingerprintProtector = FingerprintProtector.new(this, theme, volumeDatabase)
currentVolumeName = volume.name if (fingerprintProtector != null && volume.encryptedHash != null) {
if (volume.isHidden){ binding.fingerprintSwitchContainer.visibility = View.VISIBLE
switchHiddenVolume.isChecked = true
editVolumeName.setText(currentVolumeName)
} else {
switchHiddenVolume.isChecked = false
editVolumePath.setText(currentVolumeName)
}
onClickSwitchHiddenVolume()
}
} else {
WidgetUtil.hideWithPadding(binding.savedPathListview)
}
val textWatcher = object: TextWatcher{
override fun afterTextChanged(s: Editable?) {
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (volumeDatabase.isVolumeSaved(s.toString())){
checkboxRememberPath.isEnabled = false
checkboxRememberPath.isChecked = false
binding.editOldPassword.apply {
if (volumeDatabase.isHashSaved(s.toString())){
text = null
hint = getString(R.string.hash_saved_hint)
isEnabled = false
} else {
hint = null
isEnabled = true
}
}
} else {
checkboxRememberPath.isEnabled = true
binding.editOldPassword.apply {
hint = null
isEnabled = true
}
}
} }
} }
editVolumePath.addTextChangedListener(textWatcher) if (!usfFingerprint || fingerprintProtector == null) {
editVolumeName.addTextChangedListener(textWatcher) binding.checkboxSavePassword.visibility = View.GONE
binding.editNewPasswordConfirm.setOnEditorActionListener { _, _, _ -> }
checkVolumePathThenChangePassword() if (sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false)) {
arrayOf(binding.editCurrentPassword, binding.editNewPassword, binding.editPasswordConfirm).forEach {
it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
}
}
binding.fingerprintSwitchContainer.setOnClickListener {
binding.switchUseFingerprint.toggle()
}
binding.switchUseFingerprint.setOnCheckedChangeListener { _, isChecked ->
if (isChecked && binding.editCurrentPassword.hasFocus()) {
binding.editCurrentPassword.clearFocus()
inputMethodManager.hideSoftInputFromWindow(binding.editCurrentPassword.windowToken, 0)
}
}
binding.editCurrentPassword.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
binding.switchUseFingerprint.isChecked = false
}
}
binding.editPasswordConfirm.setOnEditorActionListener { _, _, _ ->
changeVolumePassword()
true true
} }
binding.buttonChangePassword.setOnClickListener { binding.button.setOnClickListener { changeVolumePassword() }
checkVolumePathThenChangePassword()
}
} }
override fun onDirectoryPicked(uri: Uri) { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (PathUtils.isTreeUriOnPrimaryStorage(uri)){ return if (item.itemId == android.R.id.home) {
val path = PathUtils.getFullPathFromTreeUri(uri, this) finish()
if (path != null){ true
editVolumePath.setText(path) } else super.onOptionsItemSelected(item)
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.path_from_uri_null_error_msg)
.setPositiveButton(R.string.ok, null)
.show()
}
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.warning)
.setMessage(R.string.change_pwd_on_sdcard_error_msg)
.setPositiveButton(R.string.ok, null)
.show()
}
} }
fun checkVolumePathThenChangePassword() { private fun showCurrentPasswordInput() {
loadVolumePath { binding.textCurrentPasswordLabel.visibility = View.VISIBLE
val volumeFile = File(currentVolumePath) binding.editCurrentPassword.visibility = View.VISIBLE
if (!GocryptfsVolume.isGocryptfsVolume(volumeFile)){
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.error_not_a_volume)
.setPositiveButton(R.string.ok, null)
.show()
} else if (!volumeFile.canWrite()){
ColoredAlertDialogBuilder(this)
.setTitle(R.string.warning)
.setMessage(R.string.change_pwd_cant_write_error_msg)
.setPositiveButton(R.string.ok, null)
.show()
} else {
changePassword()
}
}
} }
private fun changePassword(givenHash: ByteArray? = null){ private fun changeVolumePassword() {
val newPassword = binding.editNewPassword.text.toString().toCharArray() val newPassword = UIUtils.encodeEditTextContent(binding.editNewPassword)
val newPasswordConfirm = binding.editNewPasswordConfirm.text.toString().toCharArray() val newPasswordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
@SuppressLint("NewApi")
if (!newPassword.contentEquals(newPasswordConfirm)) { if (!newPassword.contentEquals(newPasswordConfirm)) {
Toast.makeText(this, R.string.passwords_mismatch, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
Arrays.fill(newPassword, 0)
} else { } else {
object : LoadingTask(this, R.string.loading_msg_change_password) { var changeWithCurrentPassword = true
override fun doTask(activity: AppCompatActivity) { volume.encryptedHash?.let { encryptedHash ->
val oldPassword = binding.editOldPassword.text.toString().toCharArray() volume.iv?.let { iv ->
var returnedHash: ByteArray? = null fingerprintProtector?.let {
if (checkboxSavePassword.isChecked) { if (binding.switchUseFingerprint.isChecked) {
returnedHash = ByteArray(GocryptfsVolume.KeyLen) changeWithCurrentPassword = false
} it.listener = object : FingerprintProtector.Listener {
var changePasswordImmediately = true override fun onHashStorageReset() {
if (givenHash == null) { showCurrentPasswordInput()
var volume: Volume? = null volume.encryptedHash = null
volumeDatabase.getVolumes().forEach { testVolume -> volume.iv = null
if (testVolume.name == currentVolumeName){ }
volume = testVolume override fun onPasswordHashDecrypted(hash: ByteArray) {
changeVolumePassword(newPassword, hash)
}
override fun onPasswordHashSaved() {}
override fun onFailed(pending: Boolean) {
Arrays.fill(newPassword, 0)
}
} }
it.loadPasswordHash(volume.name, encryptedHash, iv)
} }
volume?.let { }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){ }
it.hash?.let { hash -> }
it.iv?.let { iv -> if (changeWithCurrentPassword) {
currentVolumePath = if (it.isHidden){ changeVolumePassword(newPassword)
PathUtils.pathJoin(filesDir.path, it.name) }
} else { }
it.name Arrays.fill(newPasswordConfirm, 0)
} }
stopTask {
loadPasswordHash(hash, iv, ::changePassword) private fun changeVolumePassword(newPassword: ByteArray, givenHash: ByteArray? = null) {
} val returnedHash: ObjRef<ByteArray?>? = if (binding.checkboxSavePassword.isChecked) {
changePasswordImmediately = false ObjRef(null)
} } else {
null
}
val currentPassword = if (givenHash == null) {
UIUtils.encodeEditTextContent(binding.editCurrentPassword)
} else {
null
}
object : LoadingTask<Boolean>(this, theme, R.string.loading_msg_change_password) {
override suspend fun doTask(): Boolean {
val success = if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) {
GocryptfsVolume.changePassword(
volume.getFullPath(filesDir.path),
currentPassword,
givenHash,
newPassword,
returnedHash?.apply { value = ByteArray(GocryptfsVolume.KeyLen) }?.value
)
} else {
CryfsVolume.changePassword(
volume.getFullPath(filesDir.path),
filesDir.path,
currentPassword,
givenHash,
newPassword,
returnedHash
)
}
if (success) {
if (volumeDatabase.isHashSaved(volume)) {
volumeDatabase.removeHash(volume)
}
}
if (currentPassword != null)
Arrays.fill(currentPassword, 0)
Arrays.fill(newPassword, 0)
if (givenHash != null)
Arrays.fill(givenHash, 0)
return success
}
}.startTask(lifecycleScope) { success ->
if (success) {
@SuppressLint("NewApi") // if fingerprintProtector is null checkboxSavePassword is hidden
if (binding.checkboxSavePassword.isChecked && returnedHash != null) {
fingerprintProtector!!.let {
it.listener = object : FingerprintProtector.Listener {
override fun onHashStorageReset() {
// retry
it.savePasswordHash(volume, returnedHash.value!!)
}
override fun onPasswordHashDecrypted(hash: ByteArray) {}
override fun onPasswordHashSaved() {
Arrays.fill(returnedHash.value!!, 0)
finish()
}
override fun onFailed(pending: Boolean) {
if (!pending) {
Arrays.fill(returnedHash.value!!, 0)
finish()
} }
} }
} }
it.savePasswordHash(volume, returnedHash.value!!)
} }
if (changePasswordImmediately) { } else {
if (GocryptfsVolume.changePassword(currentVolumePath, oldPassword, givenHash, newPassword, returnedHash)) { finish()
val volume = Volume(currentVolumeName, switchHiddenVolume.isChecked)
if (volumeDatabase.isHashSaved(currentVolumeName)) {
volumeDatabase.removeHash(volume)
}
if (checkboxRememberPath.isChecked) {
volumeDatabase.saveVolume(volume)
}
if (checkboxSavePassword.isChecked && returnedHash != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
stopTask {
savePasswordHash(returnedHash) {
onPasswordChanged()
}
}
} else {
stopTask { onPasswordChanged() }
}
} else {
stopTask {
ColoredAlertDialogBuilder(activity)
.setTitle(R.string.error)
.setMessage(R.string.change_password_failed)
.setPositiveButton(R.string.ok, null)
.show()
}
}
}
Arrays.fill(oldPassword, 0.toChar())
}
override fun doFinally(activity: AppCompatActivity) {
Arrays.fill(newPassword, 0.toChar())
Arrays.fill(newPasswordConfirm, 0.toChar())
} }
} else {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.change_password_failed)
.setPositiveButton(R.string.ok, null)
.show()
} }
} }
} }
private fun onPasswordChanged(){ override fun onStop() {
ColoredAlertDialogBuilder(this) super.onStop()
.setTitle(R.string.success_change_password) binding.editCurrentPassword.text.clear()
.setMessage(R.string.success_change_password_msg) binding.editNewPassword.text.clear()
.setCancelable(false) binding.editPasswordConfirm.text.clear()
.setPositiveButton(R.string.ok) { _, _ -> finish() }
.show()
}
override fun onDestroy() {
super.onDestroy()
Wiper.wipeEditText(binding.editOldPassword)
Wiper.wipeEditText(binding.editNewPassword)
Wiper.wipeEditText(binding.editNewPasswordConfirm)
} }
} }

View File

@ -0,0 +1,20 @@
package sushi.hardcore.droidfs
import android.app.Service
import android.content.Intent
/**
* Dummy background service listening for application task removal in order to
* close all volumes still open on quit.
*
* Should only be running when usfBackground is enabled AND usfKeepOpen is disabled.
*/
class ClosingService : Service() {
override fun onBind(intent: Intent) = null
override fun onTaskRemoved(rootIntent: Intent) {
super.onTaskRemoved(rootIntent)
(application as VolumeManagerApp).volumeManager.closeAll()
stopSelf()
}
}

View File

@ -1,11 +0,0 @@
package sushi.hardcore.droidfs
import android.app.Application
import com.jaredrummler.cyanea.Cyanea
class ColoredApplication: Application() {
override fun onCreate() {
super.onCreate()
Cyanea.init(this, resources)
}
}

View File

@ -1,51 +0,0 @@
package sushi.hardcore.droidfs
import android.net.Uri
import java.io.File
class ConstValues {
companion object {
const val creator = "DroidFS"
const val gocryptfsConfFilename = "gocryptfs.conf"
const val FILE_MODE = 384 //0600
const val DIRECTORY_MODE = 448 //0700
const val volumeDatabaseName = "SavedVolumes"
const val sort_order_key = "sort_order"
val fakeUri: Uri = Uri.parse("fakeuri://droidfs")
const val MAX_KERNEL_WRITE = 128*1024
const val wipe_passes = 2
const val slideshow_delay: Long = 4000
private val fileExtensions = mapOf(
Pair("image", listOf("png", "jpg", "jpeg", "gif", "bmp")),
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac")),
Pair("text", listOf("txt", "json", "conf", "log", "xml", "java", "kt", "py", "pl", "rb", "go", "c", "h", "cpp", "hpp", "rs", "sh", "bat", "js", "html", "css", "php", "yml", "yaml", "toml", "ini", "md", "properties"))
)
fun isExtensionType(extensionType: String, path: String): Boolean {
return fileExtensions[extensionType]?.contains(File(path).extension.lowercase()) ?: false
}
fun isImage(path: String): Boolean {
return isExtensionType("image", path)
}
fun isVideo(path: String): Boolean {
return isExtensionType("video", path)
}
fun isAudio(path: String): Boolean {
return isExtensionType("audio", path)
}
fun isText(path: String): Boolean {
return isExtensionType("text", path)
}
fun getAssociatedDrawable(path: String): Int {
return when {
isAudio(path) -> R.drawable.icon_file_audio
isImage(path) -> R.drawable.icon_file_image
isVideo(path) -> R.drawable.icon_file_video
isText(path) -> R.drawable.icon_file_text
else -> R.drawable.icon_file_unknown
}
}
}
}

View File

@ -0,0 +1,19 @@
package sushi.hardcore.droidfs
import android.net.Uri
object Constants {
const val VOLUME_DATABASE_NAME = "SavedVolumes"
const val CRYFS_LOCAL_STATE_DIR = "cryfsLocalState"
const val SORT_ORDER_KEY = "sort_order"
val FAKE_URI: Uri = Uri.parse("fakeuri://droidfs")
const val WIPE_PASSES = 2
const val IO_BUFF_SIZE = 16384
const val SLIDESHOW_DELAY: Long = 4000
const val DEFAULT_THEME_VALUE = "dark_green"
const val DEFAULT_VOLUME_KEY = "default_volume"
const val REMEMBER_VOLUME_KEY = "remember_volume"
const val THUMBNAIL_MAX_SIZE_KEY = "thumbnail_max_size"
const val DEFAULT_THUMBNAIL_MAX_SIZE = 10_000L
const val PIN_PASSWORDS_KEY = "pin_passwords"
}

View File

@ -1,190 +0,0 @@
package sushi.hardcore.droidfs
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import sushi.hardcore.droidfs.databinding.ActivityCreateBinding
import sushi.hardcore.droidfs.explorers.ExplorerActivity
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.Wiper
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
import java.io.File
import java.util.*
class CreateActivity : VolumeActionActivity() {
private var sessionID = -1
private var isStartingExplorer = false
private lateinit var binding: ActivityCreateBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCreateBinding.inflate(layoutInflater)
setContentView(binding.root)
setupLayout()
setupFingerprintStuff()
binding.editPasswordConfirm.setOnEditorActionListener { _, _, _ ->
createVolume()
true
}
binding.buttonCreate.setOnClickListener {
createVolume()
}
}
override fun onClickSwitchHiddenVolume() {
super.onClickSwitchHiddenVolume()
if (switchHiddenVolume.isChecked){
ColoredAlertDialogBuilder(this)
.setTitle(R.string.warning)
.setMessage(R.string.hidden_volume_warning)
.setPositiveButton(R.string.ok, null)
.show()
}
}
override fun onDirectoryPicked(uri: Uri) {
if (PathUtils.isTreeUriOnPrimaryStorage(uri)){
val path = PathUtils.getFullPathFromTreeUri(uri, this)
if (path != null){
editVolumePath.setText(path)
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.path_from_uri_null_error_msg)
.setPositiveButton(R.string.ok, null)
.show()
}
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.warning)
.setMessage(R.string.create_on_sdcard_error_msg)
.setPositiveButton(R.string.ok, null)
.show()
}
}
fun createVolume() {
loadVolumePath {
val password = binding.editPassword.text.toString().toCharArray()
val passwordConfirm = binding.editPasswordConfirm.text.toString().toCharArray()
if (!password.contentEquals(passwordConfirm)) {
Toast.makeText(this, R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
} else {
object: LoadingTask(this, R.string.loading_msg_create){
override fun doTask(activity: AppCompatActivity) {
val volumeFile = File(currentVolumePath)
var goodDirectory = false
if (!volumeFile.isDirectory) {
if (volumeFile.mkdirs()) {
goodDirectory = true
} else {
stopTask {
ColoredAlertDialogBuilder(activity)
.setTitle(R.string.warning)
.setMessage(R.string.create_cant_write_error_msg)
.setPositiveButton(R.string.ok, null)
.show()
}
}
} else {
val dirContent = volumeFile.list()
if (dirContent != null){
if (dirContent.isEmpty()) {
if (volumeFile.canWrite()){
goodDirectory = true
} else {
stopTask {
ColoredAlertDialogBuilder(activity)
.setTitle(R.string.warning)
.setMessage(R.string.create_cant_write_error_msg)
.setPositiveButton(R.string.ok, null)
.show()
}
}
} else {
stopTaskWithToast(R.string.dir_not_empty)
}
} else {
stopTaskWithToast(R.string.listdir_null_error_msg)
}
}
if (goodDirectory) {
if (GocryptfsVolume.createVolume(currentVolumePath, password, false, GocryptfsVolume.ScryptDefaultLogN, ConstValues.creator)) {
var returnedHash: ByteArray? = null
if (checkboxSavePassword.isChecked){
returnedHash = ByteArray(GocryptfsVolume.KeyLen)
}
sessionID = GocryptfsVolume.init(currentVolumePath, password, null, returnedHash)
if (sessionID != -1) {
if (checkboxRememberPath.isChecked) {
if (volumeDatabase.isVolumeSaved(currentVolumeName)) { //cleaning old saved path
volumeDatabase.removeVolume(Volume(currentVolumeName))
}
volumeDatabase.saveVolume(Volume(currentVolumeName, switchHiddenVolume.isChecked))
}
if (checkboxSavePassword.isChecked && returnedHash != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
stopTask {
savePasswordHash(returnedHash) {
startExplorer()
}
}
} else {
stopTask { startExplorer() }
}
} else {
stopTaskWithToast(R.string.open_volume_failed)
}
} else {
stopTask {
ColoredAlertDialogBuilder(activity)
.setTitle(R.string.error)
.setMessage(R.string.create_volume_failed)
.setPositiveButton(R.string.ok, null)
.show()
}
}
}
}
override fun doFinally(activity: AppCompatActivity) {
Arrays.fill(password, 0.toChar())
Arrays.fill(passwordConfirm, 0.toChar())
}
}
}
}
}
private fun startExplorer(){
ColoredAlertDialogBuilder(this)
.setTitle(R.string.success_volume_create)
.setMessage(R.string.success_volume_create_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ ->
val intent = Intent(this, ExplorerActivity::class.java)
intent.putExtra("sessionID", sessionID)
intent.putExtra("volume_name", File(currentVolumeName).name)
startActivity(intent)
isStartingExplorer = true
finish()
}
.show()
}
override fun onPause() {
super.onPause()
//Closing volume if leaving activity while showing dialog
if (sessionID != -1 && !isStartingExplorer) {
GocryptfsVolume(sessionID).close()
finish()
}
}
override fun onDestroy() {
super.onDestroy()
Wiper.wipeEditText(binding.editPassword)
Wiper.wipeEditText(binding.editPasswordConfirm)
}
}

View File

@ -0,0 +1,250 @@
package sushi.hardcore.droidfs
import android.app.ActivityManager
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.ParcelFileDescriptor
import android.system.Os
import android.util.Log
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.Wiper
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
class EncryptedFileProvider(context: Context) {
companion object {
private const val TAG = "EncryptedFileProvider"
fun getTmpFilesDir(context: Context) = File(context.cacheDir, "tmp")
var exportMethod = ExportMethod.AUTO
}
enum class ExportMethod {
AUTO,
DISK,
MEMORY;
companion object {
fun parse(value: String) = when (value) {
"auto" -> EncryptedFileProvider.ExportMethod.AUTO
"disk" -> EncryptedFileProvider.ExportMethod.DISK
"memory" -> EncryptedFileProvider.ExportMethod.MEMORY
else -> throw IllegalArgumentException("Invalid export method: $value")
}
}
}
private val memoryInfo = ActivityManager.MemoryInfo()
private val isMemFileSupported = Compat.isMemFileSupported()
private val tmpFilesDir by lazy { getTmpFilesDir(context) }
private val handler by lazy { Handler(context.mainLooper) }
init {
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(
memoryInfo
)
PreferenceManager.getDefaultSharedPreferences(context)
.getString("export_method", null)?.let {
exportMethod = ExportMethod.parse(it)
}
}
class ExportedDiskFile private constructor(
path: String,
private val file: File,
private val handler: Handler
) : ExportedFile(path) {
companion object {
fun create(path: String, tmpFilesDir: File, handler: Handler): ExportedDiskFile? {
val uuid = UUID.randomUUID().toString()
val file = File(tmpFilesDir, uuid)
return if (file.createNewFile()) {
ExportedDiskFile(path, file, handler)
} else {
null
}
}
}
override fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor {
return if (furtive) {
ParcelFileDescriptor.open(file, mode, handler) {
free()
}
} else {
ParcelFileDescriptor.open(file, mode)
}
}
override fun free() {
Wiper.wipe(file)
}
}
class ExportedMemFile private constructor(path: String, private val file: MemFile) :
ExportedFile(path) {
companion object {
fun create(path: String, size: Long): ExportedMemFile? {
val uuid = UUID.randomUUID().toString()
MemFile.create(uuid, size)?.let {
return ExportedMemFile(path, it)
}
return null
}
}
override fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor {
val fd = if (furtive) {
file.toParcelFileDescriptor()
} else {
file.dup()
}
if (mode and ParcelFileDescriptor.MODE_TRUNCATE != 0) {
Os.ftruncate(fd.fileDescriptor, 0)
} else {
FileInputStream(fd.fileDescriptor).apply {
channel.position(0)
close()
}
}
return fd
}
override fun free() = file.close()
}
abstract class ExportedFile(val path: String) {
var isValid = true
private set
fun invalidate() {
isValid = false
}
/**
* @param furtive If set to true, the file will be deleted when closed
*/
abstract fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor
abstract fun free()
}
fun createFile(
path: String,
size: Long,
): ExportedFile? {
val diskFile by lazy { ExportedDiskFile.create(path, tmpFilesDir, handler) }
val memFile by lazy { ExportedMemFile.create(path, size) }
return when (exportMethod) {
ExportMethod.MEMORY -> memFile
ExportMethod.DISK -> diskFile
ExportMethod.AUTO -> {
if (isMemFileSupported && size < memoryInfo.availMem * 0.8) {
memFile
} else {
diskFile
}
}
}
}
fun exportFile(
exportedFile: ExportedFile,
encryptedVolume: EncryptedVolume,
): Boolean {
val fd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false).fileDescriptor
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(fd))
}
enum class Error {
SUCCESS,
INVALID_STATE,
WRITE_ACCESS_DENIED,
UNSUPPORTED_APPEND,
UNSUPPORTED_RW,
;
fun log() {
Log.e(
TAG, when (this) {
SUCCESS -> "No error"
INVALID_STATE -> "Read after write is not supported"
WRITE_ACCESS_DENIED -> "Write access unauthorized"
UNSUPPORTED_APPEND -> "Appending is not supported"
UNSUPPORTED_RW -> "Read-write access requires Android 11 or later"
}
)
}
}
/**
* @param furtive If set to true, the file will be deleted when closed
*/
fun openFile(
file: ExportedFile,
mode: String,
encryptedVolume: EncryptedVolume,
volumeScope: CoroutineScope,
furtive: Boolean,
allowWrites: Boolean,
): Pair<ParcelFileDescriptor?, Error> {
val mode = ParcelFileDescriptor.parseMode(mode)
return if (mode and ParcelFileDescriptor.MODE_READ_ONLY != 0) {
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
Pair(file.open(mode, furtive), Error.SUCCESS)
} else {
if (!allowWrites) {
return Pair(null, Error.WRITE_ACCESS_DENIED)
}
fun import(input: InputStream): Boolean {
return if (encryptedVolume.importFile(input, file.path)) {
true
} else {
Log.e(TAG, "Failed to import file")
false
}
}
if (mode and ParcelFileDescriptor.MODE_WRITE_ONLY != 0) {
if (mode and ParcelFileDescriptor.MODE_APPEND != 0) {
return Pair(null, Error.UNSUPPORTED_APPEND)
}
if (mode and ParcelFileDescriptor.MODE_TRUNCATE == 0) {
Log.w(TAG, "Truncating file despite not being requested")
}
val pipe = ParcelFileDescriptor.createReliablePipe()
val input = FileInputStream(pipe[0].fileDescriptor)
volumeScope.launch {
if (import(input)) {
file.invalidate()
}
}
Pair(pipe[1], Error.SUCCESS)
} else { // read-write
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val fd = file.open(mode, false)
Pair(ParcelFileDescriptor.wrap(fd, handler) { e ->
if (e == null) {
import(FileInputStream(fd.fileDescriptor))
if (furtive) {
file.free()
}
}
}, Error.SUCCESS)
} else {
Pair(null, Error.UNSUPPORTED_RW)
}
}
}
}
}

View File

@ -0,0 +1,78 @@
package sushi.hardcore.droidfs
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import java.io.File
class FileShare(context: Context) {
companion object {
private const val CONTENT_TYPE_ANY = "*/*"
private fun getContentType(filename: String, previousContentType: String?): String {
if (CONTENT_TYPE_ANY != previousContentType) {
var contentType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(File(filename).extension)
if (contentType == null) {
contentType = CONTENT_TYPE_ANY
}
if (previousContentType == null) {
return contentType
} else if (previousContentType != contentType) {
return CONTENT_TYPE_ANY
}
}
return previousContentType
}
}
private val usfSafWrite = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("usf_saf_write", false)
private fun exportFile(exportedFile: EncryptedFileProvider.ExportedFile, size: Long, volumeId: Int, previousContentType: String? = null): Pair<Uri, String>? {
val uri = TemporaryFileProvider.instance.exportFile(exportedFile, size, volumeId) ?: return null
return Pair(uri, getContentType(File(exportedFile.path).name, previousContentType))
}
fun share(files: List<Pair<String, Long>>, volumeId: Int): Pair<Intent?, Int?> {
var contentType: String? = null
val uris = ArrayList<Uri>(files.size)
for ((path, size) in files) {
val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
?: return Pair(null, R.string.export_failed_create)
val result = exportFile(exportedFile, size, volumeId, contentType)
contentType = if (result == null) {
return Pair(null, R.string.export_failed_export)
} else {
uris.add(result.first)
result.second
}
}
return Pair(Intent().apply {
type = contentType
if (uris.size == 1) {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_STREAM, uris[0])
} else {
action = Intent.ACTION_SEND_MULTIPLE
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
}
}, null)
}
fun openWith(exportedFile: EncryptedFileProvider.ExportedFile, size: Long, volumeId: Int): Pair<Intent?, Int?> {
val result = exportFile(exportedFile, size, volumeId)
return if (result == null) {
Pair(null, R.string.export_failed_export)
} else {
Pair(Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
if (usfSafWrite) {
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
setDataAndType(result.first, result.second)
}, null)
}
}
}

View File

@ -0,0 +1,87 @@
package sushi.hardcore.droidfs
import java.io.File
object FileTypes {
private val FILE_EXTENSIONS = mapOf(
Pair("image", listOf("png", "jpg", "jpeg", "gif", "avif", "webp", "bmp", "heic")),
Pair("video", listOf("mp4", "webm", "mkv", "mov", "m4v")),
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac", "opus")),
Pair("pdf", listOf("pdf")),
Pair("text", listOf(
"asc",
"asm",
"awk",
"bash",
"c",
"cfg",
"conf",
"cpp",
"css",
"csv",
"desktop",
"dot",
"g4",
"go",
"gradle",
"h",
"hpp",
"hs",
"html",
"ini",
"java",
"js",
"json",
"kt",
"lisp",
"log",
"lua",
"markdown",
"md",
"mod",
"org",
"php",
"pl",
"pro",
"properties",
"py",
"qml",
"rb",
"rc",
"rs",
"sh",
"smali",
"sql",
"srt",
"tex",
"toml",
"ts",
"txt",
"vala",
"vim",
"xml",
"yaml",
"yml",
))
)
fun isExtensionType(extensionType: String, path: String): Boolean {
return FILE_EXTENSIONS[extensionType]?.contains(File(path).extension.lowercase()) ?: false
}
fun isImage(path: String): Boolean {
return isExtensionType("image", path)
}
fun isVideo(path: String): Boolean {
return isExtensionType("video", path)
}
fun isAudio(path: String): Boolean {
return isExtensionType("audio", path)
}
fun isPDF(path: String): Boolean {
return isExtensionType("pdf", path)
}
fun isText(path: String): Boolean {
return isExtensionType("text", path)
}
}

View File

@ -0,0 +1,259 @@
package sushi.hardcore.droidfs
import android.app.KeyguardManager
import android.content.Context
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.security.KeyStore
import java.security.KeyStoreException
import java.security.UnrecoverableKeyException
import javax.crypto.*
import javax.crypto.spec.GCMParameterSpec
@RequiresApi(Build.VERSION_CODES.M)
class FingerprintProtector private constructor(
private val activity: FragmentActivity,
private val theme: Theme,
private val volumeDatabase: VolumeDatabase,
) {
interface Listener {
fun onHashStorageReset()
fun onPasswordHashDecrypted(hash: ByteArray)
fun onPasswordHashSaved()
fun onFailed(pending: Boolean)
}
companion object {
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val KEY_ALIAS = "Hash Key"
private const val KEY_SIZE = 256
private const val GCM_TAG_LEN = 128
fun canAuthenticate(context: Context): Int {
val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
return if (!keyguardManager.isKeyguardSecure)
1
else when (BiometricManager.from(context).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
BiometricManager.BIOMETRIC_SUCCESS -> 0
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> 2
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> 3
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> 4
else -> -1
}
}
fun new(
activity: FragmentActivity,
theme: Theme,
volumeDatabase: VolumeDatabase,
): FingerprintProtector? {
return if (canAuthenticate(activity) == 0)
FingerprintProtector(activity, theme, volumeDatabase)
else
null
}
}
lateinit var listener: Listener
private val biometricPrompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), object: BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
if (
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
errorCode != BiometricPrompt.ERROR_TIMEOUT
) {
Toast.makeText(activity, activity.getString(R.string.biometric_error, errString), Toast.LENGTH_SHORT).show()
}
listener.onFailed(false)
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
val cipherObject = result.cryptoObject?.cipher
if (cipherObject != null) {
try {
when (cipherActionMode) {
Cipher.ENCRYPT_MODE -> {
val cipherText = cipherObject.doFinal(dataToProcess)
volume.encryptedHash = cipherText
volume.iv = cipherObject.iv
if (volumeDatabase.addHash(volume))
listener.onPasswordHashSaved()
else
listener.onFailed(false)
}
Cipher.DECRYPT_MODE -> {
try {
val plainText = cipherObject.doFinal(dataToProcess)
listener.onPasswordHashDecrypted(plainText)
} catch (e: AEADBadTagException) {
listener.onFailed(true)
CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.error)
.setMessage(R.string.MAC_verification_failed)
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
resetHashStorage()
}
.setNegativeButton(R.string.cancel) { _, _ -> listener.onFailed(false) }
.setOnCancelListener { listener.onFailed(false) }
.show()
}
}
}
} catch (e: IllegalBlockSizeException) {
listener.onFailed(true)
CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.illegal_block_size_exception)
.setMessage(R.string.illegal_block_size_exception_msg)
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
resetHashStorage()
}
.setNegativeButton(R.string.cancel) { _, _ -> listener.onFailed(false) }
.setOnCancelListener { listener.onFailed(false) }
.show()
}
} else {
Toast.makeText(activity, R.string.error_cipher_null, Toast.LENGTH_SHORT).show()
listener.onFailed(false)
}
}
})
private lateinit var keyStore: KeyStore
private lateinit var key: SecretKey
private lateinit var cipher: Cipher
private var isCipherReady = false
private var cipherActionMode: Int? = null
private lateinit var volume: VolumeData
private lateinit var dataToProcess: ByteArray
private fun resetHashStorage() {
try {
keyStore.deleteEntry(KEY_ALIAS)
} catch (e: KeyStoreException) {
e.printStackTrace()
}
volumeDatabase.getVolumes().forEach { volume ->
volumeDatabase.removeHash(volume)
}
isCipherReady = false
Toast.makeText(activity, R.string.hash_storage_reset, Toast.LENGTH_SHORT).show()
listener.onHashStorageReset()
}
private fun prepareCipher(): Boolean {
if (!isCipherReady) {
keyStore = KeyStore.getInstance(ANDROID_KEY_STORE)
keyStore.load(null)
key = if (keyStore.containsAlias(KEY_ALIAS)) {
try {
keyStore.getKey(KEY_ALIAS, null) as SecretKey
} catch (e: UnrecoverableKeyException) {
listener.onFailed(true)
CustomAlertDialogBuilder(activity, theme)
.setTitle(activity.getString(R.string.unrecoverable_key_exception))
.setMessage(activity.getString(R.string.unrecoverable_key_exception_msg, e.localizedMessage))
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
resetHashStorage()
}
.setNegativeButton(R.string.cancel) { _, _ -> listener.onFailed(false) }
.setOnCancelListener { listener.onFailed(false) }
.show()
return false
}
} else {
val builder = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
builder.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
builder.setKeySize(KEY_SIZE)
builder.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
builder.setUserAuthenticationRequired(true)
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEY_STORE
)
keyGenerator.init(builder.build())
keyGenerator.generateKey()
}
cipher = Cipher.getInstance(
KeyProperties.KEY_ALGORITHM_AES + "/" + KeyProperties.BLOCK_MODE_GCM + "/" + KeyProperties.ENCRYPTION_PADDING_NONE
)
isCipherReady = true
}
return true
}
private fun alertKeyPermanentlyInvalidatedException() {
listener.onFailed(true)
CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.key_permanently_invalidated_exception)
.setMessage(R.string.key_permanently_invalidated_exception_msg)
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
resetHashStorage()
}
.setNegativeButton(R.string.cancel) { _, _ -> listener.onFailed(false) }
.setOnCancelListener { listener.onFailed(false) }
.show()
}
fun savePasswordHash(volume: VolumeData, plainText: ByteArray) {
this.volume = volume
val biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(activity.getString(R.string.encrypt_action_description))
.setSubtitle(volume.shortName)
.setDescription(activity.getString(R.string.fingerprint_instruction))
.setNegativeButtonText(activity.getString(R.string.cancel))
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setConfirmationRequired(false)
.build()
cipherActionMode = Cipher.ENCRYPT_MODE
if (prepareCipher()) {
try {
cipher.init(Cipher.ENCRYPT_MODE, key)
dataToProcess = plainText
biometricPrompt.authenticate(
biometricPromptInfo,
BiometricPrompt.CryptoObject(cipher)
)
} catch (e: KeyPermanentlyInvalidatedException) {
alertKeyPermanentlyInvalidatedException()
}
}
}
fun loadPasswordHash(volumeName: String, cipherText: ByteArray, iv: ByteArray) {
val biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(activity.getString(R.string.decrypt_action_description))
.setSubtitle(volumeName)
.setDescription(activity.getString(R.string.fingerprint_instruction))
.setNegativeButtonText(activity.getString(R.string.cancel))
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setConfirmationRequired(false)
.build()
cipherActionMode = Cipher.DECRYPT_MODE
if (prepareCipher()) {
dataToProcess = cipherText
val gcmSpec = GCMParameterSpec(GCM_TAG_LEN, iv)
try {
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec)
biometricPrompt.authenticate(
biometricPromptInfo,
BiometricPrompt.CryptoObject(cipher)
)
} catch (e: KeyPermanentlyInvalidatedException) {
alertKeyPermanentlyInvalidatedException()
}
}
}
}

View File

@ -1,240 +0,0 @@
package sushi.hardcore.droidfs
import android.content.Context
import android.net.Uri
import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
class GocryptfsVolume(var sessionID: Int) {
private external fun native_close(sessionID: Int)
private external fun native_is_closed(sessionID: Int): Boolean
private external fun native_list_dir(sessionID: Int, dir_path: String): MutableList<ExplorerElement>
private external fun native_open_read_mode(sessionID: Int, file_path: String): Int
private external fun native_open_write_mode(sessionID: Int, file_path: String, mode: Int): Int
private external fun native_read_file(sessionID: Int, handleID: Int, offset: Long, buff: ByteArray): Int
private external fun native_write_file(sessionID: Int, handleID: Int, offset: Long, buff: ByteArray, buff_size: Int): Int
private external fun native_truncate(sessionID: Int, handleID: Int, offset: Long): Boolean
private external fun native_path_exists(sessionID: Int, file_path: String): Boolean
private external fun native_get_size(sessionID: Int, file_path: String): Long
private external fun native_close_file(sessionID: Int, handleID: Int)
private external fun native_remove_file(sessionID: Int, file_path: String): Boolean
private external fun native_mkdir(sessionID: Int, dir_path: String, mode: Int): Boolean
private external fun native_rmdir(sessionID: Int, dir_path: String): Boolean
private external fun native_rename(sessionID: Int, old_path: String, new_path: String): Boolean
companion object {
const val KeyLen = 32
const val ScryptDefaultLogN = 16
const val DefaultBS = 4096
external fun createVolume(root_cipher_dir: String, password: CharArray, plainTextNames: Boolean, logN: Int, creator: String): Boolean
external fun init(root_cipher_dir: String, password: CharArray?, givenHash: ByteArray?, returnedHash: ByteArray?): Int
external fun changePassword(root_cipher_dir: String, old_password: CharArray?, givenHash: ByteArray?, new_password: CharArray, returnedHash: ByteArray?): Boolean
fun isGocryptfsVolume(path: File): Boolean {
if (path.isDirectory){
return File(path, ConstValues.gocryptfsConfFilename).isFile
}
return false
}
init {
System.loadLibrary("gocryptfs_jni")
}
}
fun close() {
synchronized(this){
native_close(sessionID)
}
}
fun isClosed(): Boolean {
synchronized(this){
return native_is_closed(sessionID)
}
}
fun listDir(dir_path: String): MutableList<ExplorerElement> {
synchronized(this){
return native_list_dir(sessionID, dir_path)
}
}
fun mkdir(dir_path: String): Boolean {
synchronized(this){
return native_mkdir(sessionID, dir_path, ConstValues.DIRECTORY_MODE)
}
}
fun rmdir(dir_path: String): Boolean {
synchronized(this){
return native_rmdir(sessionID, dir_path)
}
}
fun removeFile(file_path: String): Boolean {
synchronized(this){
return native_remove_file(sessionID, file_path)
}
}
fun pathExists(file_path: String): Boolean {
synchronized(this){
return native_path_exists(sessionID, file_path)
}
}
fun getSize(file_path: String): Long {
synchronized(this){
return native_get_size(sessionID, file_path)
}
}
fun closeFile(handleID: Int) {
synchronized(this){
native_close_file(sessionID, handleID)
}
}
fun openReadMode(file_path: String): Int {
synchronized(this){
return native_open_read_mode(sessionID, file_path)
}
}
fun openWriteMode(file_path: String): Int {
synchronized(this){
return native_open_write_mode(sessionID, file_path, ConstValues.FILE_MODE)
}
}
fun readFile(handleID: Int, offset: Long, buff: ByteArray): Int {
synchronized(this){
return native_read_file(sessionID, handleID, offset, buff)
}
}
fun writeFile(handleID: Int, offset: Long, buff: ByteArray, buff_size: Int): Int {
synchronized(this){
return native_write_file(sessionID, handleID, offset, buff, buff_size)
}
}
fun truncate(handleID: Int, offset: Long): Boolean {
synchronized(this) {
return native_truncate(sessionID, handleID, offset)
}
}
fun rename(old_path: String, new_path: String): Boolean {
synchronized(this) {
return native_rename(sessionID, old_path, new_path)
}
}
fun exportFile(handleID: Int, os: OutputStream): Boolean {
var offset: Long = 0
val ioBuffer = ByteArray(DefaultBS)
var length: Int
while (readFile(handleID, offset, ioBuffer).also { length = it } > 0){
os.write(ioBuffer, 0, length)
offset += length.toLong()
}
os.close()
return true
}
fun exportFile(src_path: String, os: OutputStream): Boolean {
var success = false
val srcHandleId = openReadMode(src_path)
if (srcHandleId != -1) {
success = exportFile(srcHandleId, os)
closeFile(srcHandleId)
}
return success
}
fun exportFile(src_path: String, dst_path: String): Boolean {
return exportFile(src_path, FileOutputStream(dst_path))
}
fun exportFile(context: Context, src_path: String, output_path: Uri): Boolean {
val os = context.contentResolver.openOutputStream(output_path)
if (os != null){
return exportFile(src_path, os)
}
return false
}
fun importFile(inputStream: InputStream, handleID: Int): Boolean {
var offset: Long = 0
val ioBuffer = ByteArray(DefaultBS)
var length: Int
while (inputStream.read(ioBuffer).also { length = it } > 0) {
val written = writeFile(handleID, offset, ioBuffer, length).toLong()
if (written == length.toLong()) {
offset += written
} else {
inputStream.close()
return false
}
}
closeFile(handleID)
inputStream.close()
return true
}
fun importFile(inputStream: InputStream, dst_path: String): Boolean {
var success = false
val dstHandleId = openWriteMode(dst_path)
if (dstHandleId != -1) {
success = importFile(inputStream, dstHandleId)
closeFile(dstHandleId)
}
return success
}
fun importFile(context: Context, src_uri: Uri, dst_path: String): Boolean {
val inputStream = context.contentResolver.openInputStream(src_uri)
if (inputStream != null){
return importFile(inputStream, dst_path)
}
return false
}
fun recursiveMapFiles(rootPath: String): MutableList<ExplorerElement> {
val result = mutableListOf<ExplorerElement>()
val explorerElements = listDir(rootPath)
result.addAll(explorerElements)
for (e in explorerElements){
if (e.isDirectory){
result.addAll(recursiveMapFiles(e.fullPath))
}
}
return result
}
fun recursiveRemoveDirectory(plain_directory_path: String): String? {
val explorerElements = listDir(plain_directory_path)
for (e in explorerElements) {
val fullPath = PathUtils.pathJoin(plain_directory_path, e.name)
if (e.isDirectory) {
val result = recursiveRemoveDirectory(fullPath)
result?.let { return it }
} else {
if (!removeFile(fullPath)) {
return fullPath
}
}
}
return if (!rmdir(plain_directory_path)) {
plain_directory_path
} else {
null
}
}
}

View File

@ -0,0 +1,120 @@
package sushi.hardcore.droidfs
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Parcel
import android.os.Parcelable
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.IntentCompat
class KeepAliveService: Service() {
internal class NotificationDetails(
val channel: String,
val title: String,
val text: String,
val action: NotificationAction,
) : Parcelable {
internal class NotificationAction(
val icon: Int,
val title: String,
val action: String,
)
constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readString()!!,
parcel.readString()!!,
NotificationAction(
parcel.readInt(),
parcel.readString()!!,
parcel.readString()!!,
)
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
with (parcel) {
writeString(channel)
writeString(title)
writeString(text)
writeInt(action.icon)
writeString(action.title)
writeString(action.action)
}
}
override fun describeContents() = 0
companion object CREATOR : Parcelable.Creator<NotificationDetails> {
override fun createFromParcel(parcel: Parcel) = NotificationDetails(parcel)
override fun newArray(size: Int) = arrayOfNulls<NotificationDetails>(size)
}
}
companion object {
const val ACTION_START = "start"
/**
* If [startForeground] is called before notification permission is granted,
* the notification won't appear.
*
* This action can be used once the permission is granted, to make the service
* call [startForeground] again in order to properly show the notification.
*/
const val ACTION_FOREGROUND = "foreground"
const val NOTIFICATION_CHANNEL_ID = "KeepAlive"
}
private val notificationManager by lazy {
NotificationManagerCompat.from(this)
}
private var notification: Notification? = null
override fun onBind(intent: Intent?) = null
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (intent.action == ACTION_START) {
val notificationDetails = IntentCompat.getParcelableExtra(intent, "notification", NotificationDetails::class.java)!!
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
notificationDetails.channel,
NotificationManager.IMPORTANCE_LOW
)
)
}
notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(notificationDetails.title)
.setContentText(notificationDetails.text)
.addAction(NotificationCompat.Action(
notificationDetails.action.icon,
notificationDetails.action.title,
PendingIntent.getBroadcast(
this,
0,
Intent(this, NotificationBroadcastReceiver::class.java).apply {
action = notificationDetails.action.action
},
PendingIntent.FLAG_IMMUTABLE
)
))
.build()
}
ServiceCompat.startForeground(this, startId, notification!!, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC // is there a better use case flag?
} else {
0
})
return START_NOT_STICKY
}
}

View File

@ -1,45 +1,34 @@
package sushi.hardcore.droidfs package sushi.hardcore.droidfs
import android.widget.TextView import androidx.fragment.app.FragmentActivity
import android.widget.Toast import kotlinx.coroutines.CoroutineScope
import androidx.appcompat.app.AlertDialog import kotlinx.coroutines.Dispatchers
import androidx.appcompat.app.AppCompatActivity import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import kotlinx.coroutines.withContext
import sushi.hardcore.droidfs.databinding.DialogLoadingBinding
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
abstract class LoadingTask(val activity: AppCompatActivity, loadingMessageResId: Int) { abstract class LoadingTask<T>(val activity: FragmentActivity, theme: Theme, loadingMessageResId: Int) {
private val dialogLoadingView = activity.layoutInflater.inflate(R.layout.dialog_loading, null) private val dialogLoading = CustomAlertDialogBuilder(activity, theme)
private val dialogLoading: AlertDialog = ColoredAlertDialogBuilder(activity) .setView(
.setView(dialogLoadingView) DialogLoadingBinding.inflate(activity.layoutInflater).apply {
textMessage.text = activity.getString(loadingMessageResId)
}.root
)
.setTitle(R.string.loading) .setTitle(R.string.loading)
.setCancelable(false) .setCancelable(false)
.create() .create()
private var isStopped = false
init { abstract suspend fun doTask(): T
dialogLoadingView.findViewById<TextView>(R.id.text_message).text = activity.getString(loadingMessageResId)
startTask() fun startTask(scope: CoroutineScope, onDone: (T) -> Unit) {
}
abstract fun doTask(activity: AppCompatActivity)
open fun doFinally(activity: AppCompatActivity){}
private fun startTask() {
dialogLoading.show() dialogLoading.show()
Thread { scope.launch {
doTask(activity) val result = withContext(Dispatchers.IO) {
if (!isStopped){ doTask()
dialogLoading.dismiss()
}
activity.runOnUiThread { doFinally(activity) }
}.start()
}
fun stopTask(onUiThread: (() -> Unit)?){
isStopped = true
dialogLoading.dismiss()
onUiThread?.let {
activity.runOnUiThread {
onUiThread()
} }
dialogLoading.dismiss()
onDone(result)
} }
} }
protected fun stopTaskWithToast(stringId: Int){
stopTask { Toast.makeText(activity, stringId, Toast.LENGTH_SHORT).show() }
}
} }

View File

@ -0,0 +1,88 @@
package sushi.hardcore.droidfs
import android.net.Uri
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.databinding.ActivityLogcatBinding
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.InterruptedIOException
import java.io.OutputStreamWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LogcatActivity: BaseActivity() {
private lateinit var binding: ActivityLogcatBinding
private var process: Process? = null
private val dateFormat by lazy {
SimpleDateFormat("yyyy-MM-dd_HH:mm:ss", Locale.getDefault())
}
private val saveAs = registerForActivityResult(ActivityResultContracts.CreateDocument("text/*")) { uri ->
uri?.let {
saveTo(it)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLogcatBinding.inflate(layoutInflater)
setContentView(binding.root)
title = getString(R.string.logcat_title)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
lifecycleScope.launch(Dispatchers.IO) {
try {
BufferedReader(InputStreamReader(Runtime.getRuntime().exec("logcat").also {
process = it
}.inputStream)).forEachLine {
binding.content.post {
binding.content.append("$it\n")
}
}
} catch (_: InterruptedIOException) {}
}
}
override fun onDestroy() {
super.onDestroy()
process?.destroy()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.logcat, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
finish()
true
}
R.id.save -> {
saveAs.launch("DroidFS_${dateFormat.format(Date())}.log")
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun saveTo(uri: Uri) {
lifecycleScope.launch(Dispatchers.IO) {
BufferedWriter(OutputStreamWriter(contentResolver.openOutputStream(uri))).use {
it.write(binding.content.text.toString())
}
launch(Dispatchers.Main) {
Toast.makeText(this@LogcatActivity, R.string.logcat_saved, Toast.LENGTH_SHORT).show()
}
}
}
}

View File

@ -1,49 +1,339 @@
package sushi.hardcore.droidfs package sushi.hardcore.droidfs
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
import sushi.hardcore.droidfs.adapters.VolumeAdapter
import sushi.hardcore.droidfs.add_volume.AddVolumeActivity
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.databinding.ActivityMainBinding import sushi.hardcore.droidfs.databinding.ActivityMainBinding
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerRouter
import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
import java.io.File
class MainActivity : BaseActivity() { class MainActivity : BaseActivity(), VolumeAdapter.Listener {
companion object {
private const val OPEN_DEFAULT_VOLUME = "openDefault"
}
private lateinit var binding: ActivityMainBinding
private lateinit var volumeDatabase: VolumeDatabase
private lateinit var volumeManager: VolumeManager
private lateinit var volumeAdapter: VolumeAdapter
private lateinit var volumeOpener: VolumeOpener
private var addVolume = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if ((explorerRouter.pickMode || explorerRouter.dropMode) && result.resultCode != AddVolumeActivity.RESULT_USER_BACK) {
setResult(result.resultCode, result.data) // forward result
finish()
}
}
private var selectedVolumePosition: Int? = null
private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
selectedVolumePosition?.let { unselect(it) }
}
private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null)
onDirectoryPicked(uri)
}
private lateinit var fileOperationService: FileOperationService
private lateinit var explorerRouter: ExplorerRouter
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setSupportActionBar(binding.toolbar.toolbar) if (sharedPrefs.getBoolean("applicationFirstOpening", true)) {
if (!isRecreating) { CustomAlertDialogBuilder(this, theme)
if (sharedPrefs.getBoolean("applicationFirstOpening", true)){ .setTitle(R.string.warning)
ColoredAlertDialogBuilder(this) .setMessage(R.string.usf_home_warning_msg)
.setTitle(R.string.warning) .setCancelable(false)
.setMessage(R.string.usf_home_warning_msg) .setPositiveButton(R.string.see_unsafe_features) { _, _ ->
.setCancelable(false) val intent = Intent(this, SettingsActivity::class.java)
.setPositiveButton(R.string.see_unsafe_features){ _, _ -> intent.putExtra("screen", "UnsafeFeaturesSettingsFragment")
val intent = Intent(this, SettingsActivity::class.java) startActivity(intent)
intent.putExtra("screen", "UnsafeFeaturesSettingsFragment") }
startActivity(intent) .setNegativeButton(R.string.ok, null)
.setOnDismissListener {
with (sharedPrefs.edit()) {
putBoolean("applicationFirstOpening", false)
apply()
} }
.setNegativeButton(R.string.ok, null) }
.setOnDismissListener { sharedPrefs.edit().putBoolean("applicationFirstOpening", false).apply() } .show()
.show() }
explorerRouter = ExplorerRouter(this, intent)
volumeManager = (application as VolumeManagerApp).volumeManager
volumeDatabase = VolumeDatabase(this)
volumeAdapter = VolumeAdapter(
this,
volumeDatabase,
(application as VolumeManagerApp).volumeManager,
!explorerRouter.pickMode && !explorerRouter.dropMode,
!explorerRouter.dropMode,
this,
)
binding.recyclerViewVolumes.adapter = volumeAdapter
binding.recyclerViewVolumes.layoutManager = LinearLayoutManager(this)
if (volumeAdapter.volumes.isEmpty()) {
binding.textNoVolumes.visibility = View.VISIBLE
}
if (explorerRouter.pickMode) {
title = getString(R.string.select_volume)
}
binding.fab.setOnClickListener {
addVolume.launch(Intent(this, AddVolumeActivity::class.java).also {
if (explorerRouter.dropMode || explorerRouter.pickMode) {
IntentUtils.forwardIntent(intent, it)
}
})
}
volumeOpener = VolumeOpener(this)
onBackPressedDispatcher.addCallback(this) {
if (volumeAdapter.selectedItems.isNotEmpty()) {
unselectAll()
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
} }
} }
binding.buttonOpen.setOnClickListener { volumeOpener.defaultVolumeName?.let { name ->
startActivity(OpenActivity::class.java) val state = savedInstanceState?.getBoolean(OPEN_DEFAULT_VOLUME)
if (state == true || state == null) {
try {
val volumeData = volumeAdapter.volumes.first { it.name == name }
if (!volumeManager.isOpen(volumeData)) {
openVolume(volumeData)
}
} catch (e: NoSuchElementException) {
unsetDefaultVolume()
}
}
} }
binding.buttonCreate.setOnClickListener { FileOperationService.bind(this) {
startActivity(CreateActivity::class.java) fileOperationService = it
} }
binding.buttonChangePassword.setOnClickListener { }
startActivity(ChangePasswordActivity::class.java)
override fun onStart() {
super.onStart()
// check if theme was changed (by SettingsActivity)
val newTheme = Theme.fromSharedPrefs(sharedPrefs)
if (newTheme != theme) {
recreate()
} else {
volumeAdapter.refresh()
invalidateOptionsMenu()
if (volumeAdapter.volumes.isNotEmpty()) {
binding.textNoVolumes.visibility = View.GONE
}
// refresh this in case another instance of MainActivity changes its value
volumeOpener.defaultVolumeName = sharedPrefs.getString(DEFAULT_VOLUME_KEY, null)
} }
} }
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(OPEN_DEFAULT_VOLUME, false)
}
override fun onSelectionChanged(size: Int) {
title = if (size == 0) {
getString(R.string.app_name)
} else {
getString(R.string.elements_selected, size, volumeAdapter.volumes.size)
}
}
override fun onVolumeItemClick(volume: VolumeData, position: Int) {
if (volumeAdapter.selectedItems.isEmpty())
openVolume(volume)
else
invalidateOptionsMenu()
}
override fun onVolumeItemLongClick() {
invalidateOptionsMenu()
}
private fun unselectAll(notifyChange: Boolean = true) {
volumeAdapter.unSelectAll(notifyChange)
invalidateOptionsMenu()
}
private fun unselect(position: Int) {
volumeAdapter.unselect(position)
invalidateOptionsMenu()
}
private fun removeVolume(volume: VolumeData) {
volumeManager.getVolumeId(volume)?.let { volumeManager.closeVolume(it) }
volumeDatabase.removeVolume(volume)
}
private fun removeVolumes(volumes: List<VolumeData>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
if (i < volumes.size) {
if (volumes[i].isHidden) {
if (doDeleteVolumeContent == null) {
val dialogBinding = DialogDeleteVolumeBinding.inflate(layoutInflater)
dialogBinding.textContent.text = getString(R.string.delete_hidden_volume_question, volumes[i].name)
// show checkbox only if there is at least one other hidden volume
for (j in (i+1 until volumes.size)) {
if (volumes[j].isHidden) {
dialogBinding.checkboxApplyToAll.visibility = View.VISIBLE
break
}
}
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.warning)
.setView(dialogBinding.root)
.setPositiveButton(R.string.forget_only) { _, _ ->
removeVolume(volumes[i])
removeVolumes(volumes, i + 1, if (dialogBinding.checkboxApplyToAll.isChecked) false else null)
}
.setNegativeButton(R.string.delete_volume) { _, _ ->
PathUtils.recursiveRemoveDirectory(File(volumes[i].getFullPath(filesDir.path)))
removeVolume(volumes[i])
removeVolumes(volumes, i + 1, if (dialogBinding.checkboxApplyToAll.isChecked) true else null)
}
.setOnCancelListener {
volumeAdapter.refresh()
invalidateOptionsMenu()
}
.show()
} else {
if (doDeleteVolumeContent) {
PathUtils.recursiveRemoveDirectory(File(volumes[i].getFullPath(filesDir.path)))
}
removeVolume(volumes[i])
removeVolumes(volumes, i + 1, doDeleteVolumeContent)
}
} else {
removeVolume(volumes[i])
removeVolumes(volumes, i + 1, doDeleteVolumeContent)
}
} else {
volumeAdapter.refresh()
invalidateOptionsMenu()
if (volumeAdapter.volumes.isEmpty()) {
binding.textNoVolumes.visibility = View.VISIBLE
}
}
}
private fun unsetDefaultVolume() {
with (sharedPrefs.edit()) {
remove(DEFAULT_VOLUME_KEY)
apply()
}
volumeOpener.defaultVolumeName = null
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.menu_settings -> { android.R.id.home -> {
if (explorerRouter.pickMode || explorerRouter.dropMode) {
finish()
} else {
unselectAll()
}
true
}
R.id.select_all -> {
volumeAdapter.selectAll()
invalidateOptionsMenu()
true
}
R.id.lock -> {
volumeAdapter.selectedItems.forEach {
volumeManager.getVolumeId(volumeAdapter.volumes[it])?.let { id ->
volumeManager.closeVolume(id)
}
}
unselectAll()
true
}
R.id.remove -> {
val selectedVolumes = volumeAdapter.selectedItems.map { i -> volumeAdapter.volumes[i] }
removeVolumes(selectedVolumes)
true
}
R.id.delete_password_hash -> {
for (i in volumeAdapter.selectedItems) {
if (volumeDatabase.removeHash(volumeAdapter.volumes[i]))
volumeAdapter.onVolumeDataChanged(i)
}
unselectAll(false)
true
}
R.id.change_password -> {
selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
changePassword.launch(Intent(this, ChangePasswordActivity::class.java).apply {
putExtra("volume", volumeAdapter.volumes[selectedVolumePosition!!])
})
true
}
R.id.remove_default_open -> {
unsetDefaultVolume()
unselect(volumeAdapter.selectedItems.first())
true
}
R.id.copy -> {
selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
val volume = volumeAdapter.volumes[selectedVolumePosition!!]
if (volume.isHidden) {
(application as VolumeManagerApp).isStartingExternalApp = true
PathUtils.safePickDirectory(pickDirectory, this, theme)
} else {
val hiddenVolumeFile = File(VolumeData.getHiddenVolumeFullPath(filesDir.path, volume.shortName))
if (hiddenVolumeFile.exists()) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.hidden_volume_already_exists)
.setPositiveButton(R.string.ok, null)
.show()
} else {
unselect(selectedVolumePosition!!)
copyVolume(
DocumentFile.fromFile(File(volume.name)),
DocumentFile.fromFile(hiddenVolumeFile.parentFile!!),
) {
VolumeData(
VolumeData.newUuid(),
volume.shortName,
true,
volume.type,
volume.encryptedHash,
volume.iv
)
}
}
}
true
}
R.id.rename -> {
val position = volumeAdapter.selectedItems.elementAt(0)
renameVolume(volumeAdapter.volumes[position], position)
true
}
R.id.settings -> {
val intent = Intent(this, SettingsActivity::class.java) val intent = Intent(this, SettingsActivity::class.java)
startActivity(intent) startActivity(intent)
true true
@ -54,11 +344,166 @@ class MainActivity : BaseActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main_activity, menu) menuInflater.inflate(R.menu.main_activity, menu)
val settingsVisible = !explorerRouter.pickMode && !explorerRouter.dropMode
menu.findItem(R.id.settings).isVisible = settingsVisible
if (settingsVisible) {
UIUtils.getMenuIconNeutralTint(this, menu).applyTo(R.id.settings, R.drawable.icon_settings)
}
val isSelecting = volumeAdapter.selectedItems.isNotEmpty()
menu.findItem(R.id.select_all).isVisible = isSelecting
menu.findItem(R.id.lock).isVisible = isSelecting && volumeAdapter.selectedItems.any {
i -> volumeManager.isOpen(volumeAdapter.volumes[i])
}
menu.findItem(R.id.remove).isVisible = isSelecting
menu.findItem(R.id.delete_password_hash).isVisible =
isSelecting &&
!volumeAdapter.selectedItems.any { i -> volumeAdapter.volumes[i].encryptedHash == null }
val onlyOneSelected = volumeAdapter.selectedItems.size == 1
val onlyOneAndWriteable =
onlyOneSelected &&
volumeAdapter.volumes[volumeAdapter.selectedItems.first()].canWrite(filesDir.path)
menu.findItem(R.id.change_password).isVisible = onlyOneAndWriteable
menu.findItem(R.id.remove_default_open).isVisible =
onlyOneSelected &&
volumeAdapter.volumes[volumeAdapter.selectedItems.first()].name == volumeOpener.defaultVolumeName
with(menu.findItem(R.id.copy)) {
isVisible = onlyOneSelected
if (isVisible) {
setTitle(if (volumeAdapter.volumes[volumeAdapter.selectedItems.elementAt(0)].isHidden)
R.string.copy_hidden_volume
else
R.string.copy_external_volume
)
}
}
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable && !volumeManager.isOpen(volumeAdapter.volumes[volumeAdapter.selectedItems.first()])
supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode)
return true return true
} }
fun <T> startActivity(clazz: Class<T>) { private fun onDirectoryPicked(uri: Uri) {
val intent = Intent(this, clazz) val volume = volumeAdapter.volumes[selectedVolumePosition!!]
startActivity(intent) unselect(selectedVolumePosition!!)
val dstDocumentFile = DocumentFile.fromTreeUri(this, uri)
if (dstDocumentFile == null) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.path_error)
.setPositiveButton(R.string.ok, null)
.show()
} else {
copyVolume(
DocumentFile.fromFile(File(volume.getFullPath(filesDir.path))),
dstDocumentFile,
) { dstRootDirectory ->
dstRootDirectory.name?.let { name ->
val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this)
if (path == null) null
else VolumeData(
VolumeData.newUuid(),
PathUtils.pathJoin(path, name),
false,
volume.type,
volume.encryptedHash,
volume.iv
)
}
}
}
}
/**
* Copy a volume.
*
* @param srcDocumentFile [DocumentFile] of the volume to copy
* @param dstDocumentFile [DocumentFile] of the destination PARENT FOLDER
* @param getResultVolume A function that returns the [VolumeData] corresponding to the destination volume. Takes the [DocumentFile] of the newly created volume (not the parent folder).
*/
private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> VolumeData?) {
lifecycleScope.launch {
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
when (result.taskResult.state) {
TaskResult.State.SUCCESS -> {
result.dstRootDirectory?.let {
getResultVolume(it)?.let { volume ->
volumeDatabase.saveVolume(volume)
volumeAdapter.apply {
volumes = volumeDatabase.getVolumes()
notifyItemInserted(volumes.size)
}
binding.textNoVolumes.visibility = View.GONE
Toast.makeText(this@MainActivity, R.string.copy_success, Toast.LENGTH_SHORT).show()
}
}
}
TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this@MainActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem!!.name))
.setPositiveButton(R.string.ok, null)
.show()
}
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
TaskResult.State.CANCELLED -> {}
}
}
}
private fun renameVolume(volume: VolumeData, position: Int) {
with (EditTextDialog(this, R.string.new_volume_name) { newName ->
val srcPath = File(volume.getFullPath(filesDir.path))
val dstPath = File(srcPath.parent, newName).canonicalFile
val newDBName: String
val success = if (volume.isHidden) {
if (newName.contains(PathUtils.SEPARATOR)) {
Toast.makeText(this, R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
renameVolume(volume, position)
return@EditTextDialog
}
newDBName = newName
srcPath.renameTo(dstPath)
} else {
newDBName = dstPath.path
DocumentFile.fromFile(srcPath).renameTo(newName)
}
if (success) {
volumeDatabase.renameVolume(volume, newDBName)
VolumeProvider.notifyRootsChanged(this)
volumeAdapter.onVolumeDataChanged(position)
unselect(position)
if (volume.name == volumeOpener.defaultVolumeName) {
with (sharedPrefs.edit()) {
putString(DEFAULT_VOLUME_KEY, newDBName)
apply()
}
volumeOpener.defaultVolumeName = newDBName
}
} else {
Toast.makeText(this, R.string.volume_rename_failed, Toast.LENGTH_SHORT).show()
}
}) {
setSelectedText(volume.shortName)
show()
}
}
private fun openVolume(volume: VolumeData) {
volumeOpener.openVolume(volume, true, object : VolumeOpener.VolumeOpenerCallbacks {
override fun onHashStorageReset() {
volumeAdapter.refresh()
}
override fun onVolumeOpened(id: Int) {
startActivity(explorerRouter.getExplorerIntent(id, volume.shortName))
if (explorerRouter.dropMode || explorerRouter.pickMode) {
finish()
}
}
})
}
override fun onStop() {
super.onStop()
volumeOpener.wipeSensitive()
} }
} }

View File

@ -0,0 +1,22 @@
package sushi.hardcore.droidfs
import android.os.ParcelFileDescriptor
import android.system.Os
class MemFile private constructor(private val fd: Int) {
companion object {
private external fun createMemFile(name: String, size: Long): Int
init {
System.loadLibrary("memfile")
}
fun create(name: String, size: Long): MemFile? {
val fd = createMemFile(name, size)
return if (fd > 0) MemFile(fd) else null
}
}
fun dup(): ParcelFileDescriptor = ParcelFileDescriptor.fromFd(fd)
fun toParcelFileDescriptor(): ParcelFileDescriptor = ParcelFileDescriptor.adoptFd(fd)
fun close() = Os.close(ParcelFileDescriptor.adoptFd(fd).fileDescriptor)
}

View File

@ -0,0 +1,23 @@
package sushi.hardcore.droidfs
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import sushi.hardcore.droidfs.file_operations.FileOperationService
class NotificationBroadcastReceiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
FileOperationService.ACTION_CANCEL -> {
intent.getBundleExtra("bundle")?.let { bundle ->
// TODO: use peekService instead?
val binder = (bundle.getBinder("binder") as FileOperationService.LocalBinder?)
binder?.getService()?.cancelOperation(bundle.getInt("taskId"))
}
}
VolumeManagerApp.ACTION_CLOSE_ALL_VOLUMES -> {
(context.applicationContext as VolumeManagerApp).volumeManager.closeAll()
}
}
}
}

View File

@ -1,273 +0,0 @@
package sushi.hardcore.droidfs
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.MenuItem
import android.widget.AdapterView.OnItemClickListener
import androidx.appcompat.app.AppCompatActivity
import sushi.hardcore.droidfs.adapters.SavedVolumesAdapter
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
import sushi.hardcore.droidfs.databinding.ActivityOpenBinding
import sushi.hardcore.droidfs.explorers.ExplorerActivity
import sushi.hardcore.droidfs.explorers.ExplorerActivityDrop
import sushi.hardcore.droidfs.explorers.ExplorerActivityPick
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.WidgetUtil
import sushi.hardcore.droidfs.util.Wiper
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
import java.io.File
import java.util.*
class OpenActivity : VolumeActionActivity() {
private lateinit var savedVolumesAdapter: SavedVolumesAdapter
private var sessionID = -1
private var isStartingActivity = false
private var isFinishingIntentionally = false
private lateinit var binding: ActivityOpenBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityOpenBinding.inflate(layoutInflater)
setContentView(binding.root)
setupLayout()
setupFingerprintStuff()
savedVolumesAdapter = SavedVolumesAdapter(this, volumeDatabase)
if (savedVolumesAdapter.count > 0){
binding.savedPathListview.adapter = savedVolumesAdapter
binding.savedPathListview.onItemClickListener = OnItemClickListener { _, _, position, _ ->
val volume = savedVolumesAdapter.getItem(position)
currentVolumeName = volume.name
if (volume.isHidden){
switchHiddenVolume.isChecked = true
editVolumeName.setText(currentVolumeName)
} else {
switchHiddenVolume.isChecked = false
editVolumePath.setText(currentVolumeName)
}
onClickSwitchHiddenVolume()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
volume.hash?.let { hash ->
volume.iv?.let { iv ->
currentVolumePath = if (volume.isHidden){
PathUtils.pathJoin(filesDir.path, volume.name)
} else {
volume.name
}
loadPasswordHash(hash, iv, ::openUsingPasswordHash)
}
}
}
}
} else {
WidgetUtil.hideWithPadding(binding.savedPathListview)
}
val textWatcher = object: TextWatcher {
override fun afterTextChanged(s: Editable?) {
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (volumeDatabase.isVolumeSaved(s.toString())){
checkboxRememberPath.isEnabled = false
checkboxRememberPath.isChecked = false
if (volumeDatabase.isHashSaved(s.toString())){
checkboxSavePassword.isEnabled = false
checkboxSavePassword.isChecked = false
} else {
checkboxSavePassword.isEnabled = true
}
} else {
checkboxRememberPath.isEnabled = true
checkboxSavePassword.isEnabled = true
}
}
}
editVolumePath.addTextChangedListener(textWatcher)
editVolumeName.addTextChangedListener(textWatcher)
binding.editPassword.setOnEditorActionListener { _, _, _ ->
checkVolumePathThenOpen()
true
}
binding.buttonOpen.setOnClickListener {
checkVolumePathThenOpen()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when(item.itemId){
android.R.id.home -> {
isFinishingIntentionally = true
finish()
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onPickingDirectory() {
isStartingActivity = true
}
override fun onDirectoryPicked(uri: Uri) {
val path = PathUtils.getFullPathFromTreeUri(uri, this)
if (path != null){
editVolumePath.setText(path)
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.path_from_uri_null_error_msg)
.setPositiveButton(R.string.ok, null)
.show()
}
}
fun checkVolumePathThenOpen() {
loadVolumePath {
val volumeFile = File(currentVolumePath)
if (!GocryptfsVolume.isGocryptfsVolume(volumeFile)){
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.error_not_a_volume)
.setPositiveButton(R.string.ok, null)
.show()
} else if (!volumeFile.canWrite()) {
if ((intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) && intent.extras != null) { //import via android share menu
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.open_cant_write_error_msg)
.setPositiveButton(R.string.ok, null)
.show()
} else {
val dialog = ColoredAlertDialogBuilder(this)
.setTitle(R.string.warning)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> openVolume() }
if (PathUtils.isPathOnExternalStorage(currentVolumeName, this)){
dialog.setMessage(R.string.open_on_sdcard_warning)
} else {
dialog.setMessage(R.string.open_cant_write_warning)
}
dialog.show()
}
} else {
openVolume()
}
}
}
private fun openVolume(){
object : LoadingTask(this, R.string.loading_msg_open){
override fun doTask(activity: AppCompatActivity) {
val password = binding.editPassword.text.toString().toCharArray()
var returnedHash: ByteArray? = null
if (checkboxSavePassword.isChecked){
returnedHash = ByteArray(GocryptfsVolume.KeyLen)
}
sessionID = GocryptfsVolume.init(currentVolumePath, password, null, returnedHash)
if (sessionID != -1) {
if (checkboxRememberPath.isChecked) {
volumeDatabase.saveVolume(Volume(currentVolumeName, switchHiddenVolume.isChecked))
}
if (checkboxSavePassword.isChecked && returnedHash != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
stopTask {
savePasswordHash(returnedHash) { success ->
if (success){
startExplorer()
} else {
GocryptfsVolume(sessionID).close()
}
}
}
} else {
stopTask { startExplorer() }
}
} else {
stopTask {
ColoredAlertDialogBuilder(activity)
.setTitle(R.string.open_volume_failed)
.setMessage(R.string.open_volume_failed_msg)
.setPositiveButton(R.string.ok, null)
.show()
}
}
Arrays.fill(password, 0.toChar())
}
}
}
private fun openUsingPasswordHash(passwordHash: ByteArray){
object : LoadingTask(this, R.string.loading_msg_open){
override fun doTask(activity: AppCompatActivity) {
sessionID = GocryptfsVolume.init(currentVolumePath, null, passwordHash, null)
if (sessionID != -1){
stopTask { startExplorer() }
} else {
stopTask {
ColoredAlertDialogBuilder(activity)
.setTitle(R.string.open_volume_failed)
.setMessage(R.string.open_failed_hash_msg)
.setPositiveButton(R.string.ok, null)
.show()
}
}
Arrays.fill(passwordHash, 0)
}
}
}
private fun startExplorer() {
var explorerIntent: Intent? = null
val currentIntentAction = intent.action
if (currentIntentAction != null) {
if ((currentIntentAction == Intent.ACTION_SEND || currentIntentAction == Intent.ACTION_SEND_MULTIPLE) && intent.extras != null) { //import via android share menu
explorerIntent = Intent(this, ExplorerActivityDrop::class.java)
explorerIntent.action = currentIntentAction //forward action
explorerIntent.putExtras(intent.extras!!) //forward extras
} else if (currentIntentAction == "pick") { //pick items to import
explorerIntent = Intent(this, ExplorerActivityPick::class.java)
explorerIntent.putExtra("originalSessionID", intent.getIntExtra("sessionID", -1))
explorerIntent.flags = Intent.FLAG_ACTIVITY_FORWARD_RESULT
}
}
if (explorerIntent == null) {
explorerIntent = Intent(this, ExplorerActivity::class.java) //default opening
}
explorerIntent.putExtra("sessionID", sessionID)
explorerIntent.putExtra("volume_name", File(currentVolumeName).name)
startActivity(explorerIntent)
isFinishingIntentionally = true
finish()
}
override fun onBackPressed() {
super.onBackPressed()
isFinishingIntentionally = true
}
override fun onStop() {
super.onStop()
if (intent.action == "pick"){
if (isStartingActivity) {
isStartingActivity = false
} else {
finish()
}
}
}
override fun onDestroy() {
super.onDestroy()
Wiper.wipeEditText(binding.editPassword)
if (intent.action == "pick" && !isFinishingIntentionally){
val sessionID = intent.getIntExtra("sessionID", -1)
if (sessionID != -1){
GocryptfsVolume(sessionID).close()
RestrictedFileProvider.wipeAll(this)
}
}
}
}

View File

@ -5,6 +5,7 @@ import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
import android.hardware.SensorEventListener import android.hardware.SensorEventListener
import android.hardware.SensorManager import android.hardware.SensorManager
import android.view.Surface
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.* import java.util.*
@ -15,8 +16,8 @@ class SensorOrientationListener(context: Context) {
mSensorEventListener = NotifierSensorEventListener() mSensorEventListener = NotifierSensorEventListener()
mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
} }
private val mListeners = ArrayList<WeakReference<Listener?>>(3) private val mListeners = ArrayList<WeakReference<Listener?>>(1)
private var orientation = 0 private var orientation = Surface.ROTATION_0
private fun onResume() { private fun onResume() {
mSensorManager.registerListener( mSensorManager.registerListener(
@ -35,10 +36,10 @@ class SensorOrientationListener(context: Context) {
val x = event.values[0] val x = event.values[0]
val y = event.values[1] val y = event.values[1]
var newOrientation: Int = orientation var newOrientation: Int = orientation
if (x < 5 && x > -5 && y > 5) newOrientation = 0 if (x < 5 && x > -5 && y > 5) newOrientation = Surface.ROTATION_0
else if (x < -5 && y < 5 && y > -5) newOrientation = 90 else if (x < -5 && y < 5 && y > -5) newOrientation = Surface.ROTATION_270
else if (x < 5 && x > -5 && y < -5) newOrientation = 180 else if (x < 5 && x > -5 && y < -5) newOrientation = Surface.ROTATION_180
else if (x > 5 && y < 5 && y > -5) newOrientation = 270 else if (x > 5 && y < 5 && y > -5) newOrientation = Surface.ROTATION_90
if (orientation != newOrientation) { if (orientation != newOrientation) {
orientation = newOrientation orientation = newOrientation

View File

@ -1,22 +1,35 @@
package sushi.hardcore.droidfs package sushi.hardcore.droidfs
import android.app.ActivityOptions
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.InputType
import android.view.MenuItem import android.view.MenuItem
import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import com.jaredrummler.android.colorpicker.ColorPreferenceCompat import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreference
import androidx.preference.SwitchPreferenceCompat
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.databinding.ActivitySettingsBinding import sushi.hardcore.droidfs.databinding.ActivitySettingsBinding
import sushi.hardcore.droidfs.widgets.SimpleActionPreference import sushi.hardcore.droidfs.util.AndroidUtils
import sushi.hardcore.droidfs.widgets.ThemeColor import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
class SettingsActivity : BaseActivity() { class SettingsActivity : BaseActivity() {
private val notificationPermissionHelper = AndroidUtils.NotificationPermissionHelper(this)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivitySettingsBinding.inflate(layoutInflater) val binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setSupportActionBar(binding.toolbar.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
val screen = intent.extras?.getString("screen") ?: "main" val screen = intent.extras?.getString("screen") ?: "main"
val fragment = if (screen == "UnsafeFeaturesSettingsFragment") { val fragment = if (screen == "UnsafeFeaturesSettingsFragment") {
@ -33,7 +46,7 @@ class SettingsActivity : BaseActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId){ return when (item.itemId){
android.R.id.home -> { android.R.id.home -> {
onBackPressed() //return to the previous fragment rather than the activity onBackPressedDispatcher.onBackPressed() //return to the previous fragment rather than the activity
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
@ -41,36 +54,185 @@ class SettingsActivity : BaseActivity() {
} }
class MainSettingsFragment : PreferenceFragmentCompat() { class MainSettingsFragment : PreferenceFragmentCompat() {
private lateinit var sharedPrefs: SharedPreferences
private lateinit var maxSizePreference: Preference
private fun setThumbnailMaxSize(input: String) {
val value: Long
try {
value = input.toLong()
} catch (e: NumberFormatException) {
Toast.makeText(requireContext(), R.string.invalid_number, Toast.LENGTH_SHORT).show()
showMaxSizeDialog()
return
}
val size = value*1000
if (size < 0) {
Toast.makeText(requireContext(), R.string.invalid_number, Toast.LENGTH_SHORT).show()
showMaxSizeDialog()
} else {
with(sharedPrefs.edit()) {
putLong(Constants.THUMBNAIL_MAX_SIZE_KEY, value)
apply()
}
maxSizePreference.summary = PathUtils.formatSize(size)
}
}
private fun showMaxSizeDialog() {
with (EditTextDialog((requireActivity() as BaseActivity), R.string.thumbnail_max_size) {
setThumbnailMaxSize(it)
}) {
with (binding.dialogEditText) {
inputType = InputType.TYPE_CLASS_NUMBER
hint = getString(R.string.size_hint)
}
show()
}
}
private fun refreshTheme() {
with(requireActivity()) {
startActivity(
Intent(this, SettingsActivity::class.java),
ActivityOptions.makeCustomAnimation(
this,
android.R.anim.fade_in,
android.R.anim.fade_out
).toBundle()
)
finish()
}
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.root_preferences, rootKey) setPreferencesFromResource(R.xml.root_preferences, rootKey)
ThemeColor.tintPreferenceIcons(preferenceScreen, ThemeColor.getThemeColor(requireContext())) sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
var originalThemeColor: Int? = null findPreference<ListPreference>("color")?.setOnPreferenceChangeListener { _, _ ->
context?.let { refreshTheme()
originalThemeColor = ContextCompat.getColor(it, R.color.themeColor) true
} }
findPreference<ColorPreferenceCompat>("themeColor")?.let { colorPicker -> findPreference<SwitchPreferenceCompat>("black_theme")?.setOnPreferenceChangeListener { _, _ ->
colorPicker.onPreferenceChangeListener = Preference.OnPreferenceChangeListener{ _, _ -> refreshTheme()
(activity as SettingsActivity).changeThemeColor() true
true }
} findPreference<Preference>(Constants.THUMBNAIL_MAX_SIZE_KEY)?.let {
findPreference<SimpleActionPreference>("resetThemeColor")?.onClick = { maxSizePreference = it
originalThemeColor?.let { maxSizePreference.summary = getString(
colorPicker.saveValue(it) R.string.thumbnail_max_size_summary,
val settingsActivity = (activity as SettingsActivity) PathUtils.formatSize(sharedPrefs.getLong(
Thread { Constants.THUMBNAIL_MAX_SIZE_KEY, Constants.DEFAULT_THUMBNAIL_MAX_SIZE
settingsActivity.sharedPrefs.edit().commit() )*1000)
settingsActivity.runOnUiThread { settingsActivity.changeThemeColor() } )
}.start() maxSizePreference.setOnPreferenceClickListener {
} showMaxSizeDialog()
false
} }
} }
findPreference<Preference>("logcat")?.setOnPreferenceClickListener { _ ->
startActivity(Intent(requireContext(), LogcatActivity::class.java))
true
}
} }
} }
class UnsafeFeaturesSettingsFragment : PreferenceFragmentCompat() { class UnsafeFeaturesSettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.unsafe_features_preferences, rootKey) setPreferencesFromResource(R.xml.unsafe_features_preferences, rootKey)
ThemeColor.tintPreferenceIcons(preferenceScreen, ThemeColor.getThemeColor(requireContext())) findPreference<SwitchPreference>("usf_fingerprint")?.setOnPreferenceChangeListener { _, checked ->
if (checked as Boolean) {
var errorMsg: String? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val reason = when (FingerprintProtector.canAuthenticate(requireContext())) {
0 -> null
1 -> R.string.keyguard_not_secure
2 -> R.string.no_hardware
3 -> R.string.hardware_unavailable
4 -> R.string.no_fingerprint
else -> R.string.unknown_error
}
reason?.let {
errorMsg = getString(R.string.fingerprint_error_msg, getString(it))
}
} else {
errorMsg = getString(R.string.error_marshmallow_required)
}
if (errorMsg == null) {
true
} else {
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
.setTitle(R.string.error)
.setMessage(errorMsg)
.setPositiveButton(R.string.ok, null)
.show()
false
}
} else {
true
}
}
val switchBackground = findPreference<SwitchPreference>("usf_background")!!
val switchKeepOpen = findPreference<SwitchPreference>("usf_keep_open")!!
val switchExternalOpen = findPreference<SwitchPreference>("usf_open")!!
val switchExpose = findPreference<SwitchPreference>("usf_expose")!!
val switchSafWrite = findPreference<SwitchPreference>("usf_saf_write")!!
fun onUsfBackgroundChanged(usfBackground: Boolean) {
fun updateSwitchPreference(switch: SwitchPreference) = with (switch) {
isChecked = isChecked && usfBackground
isEnabled = usfBackground
onPreferenceChangeListener?.onPreferenceChange(switch, isChecked)
}
updateSwitchPreference(switchKeepOpen)
updateSwitchPreference(switchExpose)
}
onUsfBackgroundChanged(switchBackground.isChecked)
fun updateSafWrite(usfOpen: Boolean? = null, usfExpose: Boolean? = null) {
switchSafWrite.isEnabled = usfOpen ?: switchExternalOpen.isChecked || usfExpose ?: switchExpose.isChecked
}
updateSafWrite()
switchBackground.setOnPreferenceChangeListener { _, checked ->
onUsfBackgroundChanged(checked as Boolean)
true
}
switchExternalOpen.setOnPreferenceChangeListener { _, checked ->
updateSafWrite(usfOpen = checked as Boolean)
true
}
switchExpose.setOnPreferenceChangeListener { _, checked ->
updateSafWrite(usfExpose = checked as Boolean)
VolumeProvider.notifyRootsChanged(requireContext())
true
}
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
if (checked as Boolean) {
(requireActivity() as SettingsActivity).notificationPermissionHelper.askAndRun {
requireContext().let {
if (AndroidUtils.isServiceRunning(it, KeepAliveService::class.java)) {
ContextCompat.startForegroundService(it, Intent(it, KeepAliveService::class.java).apply {
action = KeepAliveService.ACTION_FOREGROUND
})
}
}
}
}
true
}
findPreference<ListPreference>("export_method")!!.setOnPreferenceChangeListener { _, newValue ->
if (newValue as String == "memory" && !Compat.isMemFileSupported()) {
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.memfd_create_unsupported, Compat.MEMFD_CREATE_MINIMUM_KERNEL_VERSION))
.setPositiveButton(R.string.ok, null)
.show()
return@setOnPreferenceChangeListener false
}
EncryptedFileProvider.exportMethod = EncryptedFileProvider.ExportMethod.parse(newValue)
true
}
} }
} }
} }

View File

@ -0,0 +1,68 @@
package sushi.hardcore.droidfs
import android.content.SharedPreferences
import android.os.Parcel
import android.os.Parcelable
class Theme(var color: String, var black: Boolean) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readByte() != 0.toByte(),
)
fun toResourceId(): Int {
return if (black) {
when (color) {
"red" -> R.style.BlackRed
"blue" -> R.style.BlackBlue
"yellow" -> R.style.BlackYellow
"orange" -> R.style.BlackOrange
"purple" -> R.style.BlackPurple
"pink" -> R.style.BlackPink
else -> R.style.BlackGreen
}
} else {
when (color) {
"red" -> R.style.DarkRed
"blue" -> R.style.DarkBlue
"yellow" -> R.style.DarkYellow
"orange" -> R.style.DarkOrange
"purple" -> R.style.DarkPurple
"pink" -> R.style.DarkPink
else -> R.style.BaseTheme
}
}
}
override fun equals(other: Any?): Boolean {
if (other !is Theme) {
return false
}
return other.color == color && other.black == black
}
override fun describeContents(): Int = 0
override fun writeToParcel(dest: Parcel, flags: Int) {
with(dest) {
writeString(color)
writeByte(if (black) 1 else 0)
}
}
companion object {
@JvmField
val CREATOR = object : Parcelable.Creator<Theme> {
override fun createFromParcel(parcel: Parcel) = Theme(parcel)
override fun newArray(size: Int) = arrayOfNulls<Theme>(size)
}
fun fromSharedPrefs(sharedPrefs: SharedPreferences): Theme {
val color = sharedPrefs.getString("color", "green")!!
val black = sharedPrefs.getBoolean("black_theme", false)
return Theme(color, black)
}
}
}

View File

@ -0,0 +1,92 @@
package sushi.hardcore.droidfs
import android.content.Context
import android.graphics.drawable.Drawable
import android.widget.ImageView
import androidx.lifecycle.LifecycleCoroutineScope
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.DrawableImageViewTarget
import com.bumptech.glide.request.transition.Transition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
class ThumbnailsLoader(
private val context: Context,
private val encryptedVolume: EncryptedVolume,
private val maxSize: Long,
private val lifecycleScope: LifecycleCoroutineScope
) {
internal class ThumbnailData(val id: Int, val path: String, val imageView: ImageView, val onLoaded: (Drawable) -> Unit)
internal class ThumbnailTask(var senderJob: Job?, var workerJob: Job?, var target: DrawableImageViewTarget?)
private val concurrentTasks = Runtime.getRuntime().availableProcessors()/4
private val channel = Channel<ThumbnailData>(concurrentTasks)
private var taskId = 0
private val tasks = HashMap<Int, ThumbnailTask>()
private suspend fun loadThumbnail(data: ThumbnailData) {
withContext(Dispatchers.IO) {
encryptedVolume.loadWholeFile(data.path, maxSize = maxSize).first?.let {
yield()
withContext(Dispatchers.Main) {
tasks[data.id]?.let { task ->
val channel = Channel<Unit>(1)
task.target = Glide.with(context).load(it).skipMemoryCache(true).into(object : DrawableImageViewTarget(data.imageView) {
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
super.onResourceReady(resource, transition)
data.onLoaded(resource)
channel.trySend(Unit)
}
})
channel.receive()
tasks.remove(data.id)
}
}
}
}
}
fun initialize() {
for (i in 0 until concurrentTasks) {
lifecycleScope.launch {
while (true) {
val data = channel.receive()
val workerJob = launch {
loadThumbnail(data)
}
tasks[data.id]?.workerJob = workerJob
workerJob.join()
}
}
}
}
fun loadAsync(path: String, target: ImageView, onLoaded: (Drawable) -> Unit): Int {
val id = taskId++
tasks[id] = ThumbnailTask(null, null, null)
val senderJob = lifecycleScope.launch {
channel.send(ThumbnailData(id, path, target, onLoaded))
}
tasks[id]!!.senderJob = senderJob
return id
}
fun cancel(id: Int) {
tasks[id]?.let { task ->
task.senderJob?.cancel()
task.workerJob?.cancel()
task.target?.let {
Glide.with(context).clear(it)
}
}
tasks.remove(id)
}
}

View File

@ -1,3 +0,0 @@
package sushi.hardcore.droidfs
class Volume(val name: String, val isHidden: Boolean = false, var hash: ByteArray? = null, var iv: ByteArray? = null)

View File

@ -1,369 +0,0 @@
package sushi.hardcore.droidfs
import android.Manifest
import android.app.KeyguardManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.KeyProperties
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.widget.SwitchCompat
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.WidgetUtil
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.ColoredImageButton
import java.security.KeyStore
import javax.crypto.*
import javax.crypto.spec.GCMParameterSpec
abstract class VolumeActionActivity : BaseActivity() {
companion object {
private const val STORAGE_PERMISSIONS_REQUEST = 0
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val KEY_ALIAS = "Hash Key"
private const val KEY_SIZE = 256
private const val GCM_TAG_LEN = 128
}
protected lateinit var currentVolumeName: String
protected lateinit var currentVolumePath: String
protected lateinit var volumeDatabase: VolumeDatabase
protected val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
onDirectoryPicked(uri)
}
}
private var usf_fingerprint = false
private var biometricCanAuthenticateCode: Int = -1
private lateinit var biometricManager: BiometricManager
private lateinit var biometricPrompt: BiometricPrompt
private lateinit var keyStore: KeyStore
private lateinit var key: SecretKey
private lateinit var cipher: Cipher
private var isCipherReady = false
private var actionMode: Int? = null
private lateinit var onAuthenticationResult: (success: Boolean) -> Unit
private lateinit var onPasswordDecrypted: (password: ByteArray) -> Unit
private lateinit var dataToProcess: ByteArray
private lateinit var originalHiddenVolumeSectionLayoutParams: LinearLayout.LayoutParams
private lateinit var originalNormalVolumeSectionLayoutParams: LinearLayout.LayoutParams
protected lateinit var switchHiddenVolume: SwitchCompat
protected lateinit var checkboxRememberPath: CheckBox
protected lateinit var checkboxSavePassword: CheckBox
protected lateinit var editVolumeName: EditText
protected lateinit var editVolumePath: EditText
private lateinit var hiddenVolumeSection: LinearLayout
private lateinit var normalVolumeSection: LinearLayout
protected fun setupLayout() {
setSupportActionBar(findViewById(R.id.toolbar))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
findViewById<ColoredImageButton>(R.id.button_pick_directory).setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) +
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), STORAGE_PERMISSIONS_REQUEST)
} else {
safePickDirectory()
}
} else {
safePickDirectory()
}
}
switchHiddenVolume = findViewById(R.id.switch_hidden_volume)
checkboxRememberPath = findViewById(R.id.checkbox_remember_path)
checkboxSavePassword = findViewById(R.id.checkbox_save_password)
editVolumeName = findViewById(R.id.edit_volume_name)
editVolumePath = findViewById(R.id.edit_volume_path)
hiddenVolumeSection = findViewById(R.id.hidden_volume_section)
normalVolumeSection = findViewById(R.id.normal_volume_section)
switchHiddenVolume.setOnClickListener {
onClickSwitchHiddenVolume()
}
checkboxRememberPath.setOnClickListener {
if (!checkboxRememberPath.isChecked) {
checkboxSavePassword.isChecked = false
}
}
checkboxSavePassword.setOnClickListener {
if (checkboxSavePassword.isChecked) {
if (biometricCanAuthenticateCode == 0) {
checkboxRememberPath.isChecked = checkboxRememberPath.isEnabled
} else {
checkboxSavePassword.isChecked = false
printAuthenticateImpossibleError()
}
}
}
}
protected open fun onClickSwitchHiddenVolume() {
if (switchHiddenVolume.isChecked){
WidgetUtil.show(hiddenVolumeSection, originalHiddenVolumeSectionLayoutParams)
WidgetUtil.hide(normalVolumeSection)
} else {
WidgetUtil.show(normalVolumeSection, originalNormalVolumeSectionLayoutParams)
WidgetUtil.hide(hiddenVolumeSection)
}
}
protected open fun onPickingDirectory() {}
protected abstract fun onDirectoryPicked(uri: Uri)
private fun safePickDirectory() {
try {
onPickingDirectory()
pickDirectory.launch(null)
} catch (e: ActivityNotFoundException) {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.open_tree_failed)
.setPositiveButton(R.string.ok, null)
.show()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
STORAGE_PERMISSIONS_REQUEST -> if (grantResults.size == 2) {
if (grantResults[0] != PackageManager.PERMISSION_GRANTED || grantResults[1] != PackageManager.PERMISSION_GRANTED) {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.storage_perm_denied)
.setMessage(R.string.storage_perm_denied_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok, null)
.show()
} else {
safePickDirectory()
}
}
}
}
protected fun setupFingerprintStuff(){
originalHiddenVolumeSectionLayoutParams = hiddenVolumeSection.layoutParams as LinearLayout.LayoutParams
originalNormalVolumeSectionLayoutParams = normalVolumeSection.layoutParams as LinearLayout.LayoutParams
WidgetUtil.hide(hiddenVolumeSection)
volumeDatabase = VolumeDatabase(this)
usf_fingerprint = sharedPrefs.getBoolean("usf_fingerprint", false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && usf_fingerprint) {
biometricManager = BiometricManager.from(this)
biometricCanAuthenticateCode = canAuthenticate()
if (biometricCanAuthenticateCode == 0){
val executor = ContextCompat.getMainExecutor(this)
val activityContext = this
val callback = object: BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
Toast.makeText(applicationContext, errString, Toast.LENGTH_SHORT).show()
if (actionMode == Cipher.ENCRYPT_MODE){
onAuthenticationResult(false)
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
Toast.makeText(applicationContext, R.string.authentication_failed, Toast.LENGTH_SHORT).show()
if (actionMode == Cipher.ENCRYPT_MODE){
onAuthenticationResult(false)
}
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
var success = false
val cipherObject = result.cryptoObject?.cipher
if (cipherObject != null){
try {
when (actionMode) {
Cipher.ENCRYPT_MODE -> {
val cipherText = cipherObject.doFinal(dataToProcess)
success = volumeDatabase.addHash(Volume(currentVolumeName, switchHiddenVolume.isChecked, cipherText, cipherObject.iv))
}
Cipher.DECRYPT_MODE -> {
try {
val plainText = cipherObject.doFinal(dataToProcess)
onPasswordDecrypted(plainText)
} catch (e: AEADBadTagException){
ColoredAlertDialogBuilder(activityContext)
.setTitle(R.string.error)
.setMessage(R.string.MAC_verification_failed)
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
resetHashStorage()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
}
} catch (e: IllegalBlockSizeException){
ColoredAlertDialogBuilder(activityContext)
.setTitle(R.string.illegal_block_size_exception)
.setMessage(R.string.illegal_block_size_exception_msg)
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
resetHashStorage()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
} else {
Toast.makeText(applicationContext, R.string.error_cipher_null, Toast.LENGTH_SHORT).show()
}
if (actionMode == Cipher.ENCRYPT_MODE){
onAuthenticationResult(success)
}
}
}
biometricPrompt = BiometricPrompt(this, executor, callback)
}
} else {
WidgetUtil.hideWithPadding(checkboxSavePassword)
}
}
private fun canAuthenticate(): Int {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
return if (!keyguardManager.isKeyguardSecure) {
1
} else {
when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)){
BiometricManager.BIOMETRIC_SUCCESS -> 0
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> 2
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> 3
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> 4
else -> -1
}
}
}
private fun printAuthenticateImpossibleError() {
Toast.makeText(this, when (biometricCanAuthenticateCode){
1 -> R.string.fingerprint_error_no_fingerprints
2 -> R.string.fingerprint_error_hw_not_present
3 -> R.string.fingerprint_error_hw_not_available
4 -> R.string.fingerprint_error_no_fingerprints
else -> R.string.error
}, Toast.LENGTH_SHORT).show()
}
@RequiresApi(Build.VERSION_CODES.M)
private fun prepareCipher() {
keyStore = KeyStore.getInstance(ANDROID_KEY_STORE)
keyStore.load(null)
key = if (keyStore.containsAlias(KEY_ALIAS)){
keyStore.getKey(KEY_ALIAS, null) as SecretKey
} else {
val builder = KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
builder.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
builder.setKeySize(KEY_SIZE)
builder.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
builder.setUserAuthenticationRequired(true)
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEY_STORE
)
keyGenerator.init(builder.build())
keyGenerator.generateKey()
}
cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES+"/"+KeyProperties.BLOCK_MODE_GCM+"/"+KeyProperties.ENCRYPTION_PADDING_NONE)
isCipherReady = true
}
private fun alertKeyPermanentlyInvalidatedException(){
ColoredAlertDialogBuilder(this)
.setTitle(R.string.key_permanently_invalidated_exception)
.setMessage(R.string.key_permanently_invalidated_exception_msg)
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
resetHashStorage()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
@RequiresApi(Build.VERSION_CODES.M)
protected fun savePasswordHash(plainText: ByteArray, onAuthenticationResult: (success: Boolean) -> Unit){
val biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(currentVolumeName)
.setSubtitle(getString(R.string.encrypt_action_description))
.setDescription(getString(R.string.fingerprint_instruction))
.setNegativeButtonText(getString(R.string.cancel))
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setConfirmationRequired(false)
.build()
if (!isCipherReady){
prepareCipher()
}
actionMode = Cipher.ENCRYPT_MODE
try {
cipher.init(Cipher.ENCRYPT_MODE, key)
this.onAuthenticationResult = onAuthenticationResult
dataToProcess = plainText
biometricPrompt.authenticate(biometricPromptInfo, BiometricPrompt.CryptoObject(cipher))
} catch (e: KeyPermanentlyInvalidatedException){
alertKeyPermanentlyInvalidatedException()
}
}
@RequiresApi(Build.VERSION_CODES.M)
protected fun loadPasswordHash(cipherText: ByteArray, iv: ByteArray, onPasswordDecrypted: (password: ByteArray) -> Unit){
val biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(currentVolumeName)
.setSubtitle(getString(R.string.decrypt_action_description))
.setDescription(getString(R.string.fingerprint_instruction))
.setNegativeButtonText(getString(R.string.cancel))
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.setConfirmationRequired(false)
.build()
this.onPasswordDecrypted = onPasswordDecrypted
actionMode = Cipher.DECRYPT_MODE
if (!isCipherReady){
prepareCipher()
}
dataToProcess = cipherText
val gcmSpec = GCMParameterSpec(GCM_TAG_LEN, iv)
try {
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec)
biometricPrompt.authenticate(biometricPromptInfo, BiometricPrompt.CryptoObject(cipher))
} catch (e: KeyPermanentlyInvalidatedException){
alertKeyPermanentlyInvalidatedException()
}
}
private fun resetHashStorage() {
keyStore.deleteEntry(KEY_ALIAS)
volumeDatabase.getVolumes().forEach { volume ->
volumeDatabase.removeHash(volume)
}
isCipherReady = false
Toast.makeText(this, R.string.hash_storage_reset, Toast.LENGTH_SHORT).show()
}
protected fun loadVolumePath(callback: () -> Unit){
currentVolumeName = if (switchHiddenVolume.isChecked){
editVolumeName.text.toString()
} else {
editVolumePath.text.toString()
}
if (currentVolumeName.isEmpty()) {
Toast.makeText(this, if (switchHiddenVolume.isChecked) {R.string.enter_volume_name} else {R.string.enter_volume_path}, Toast.LENGTH_SHORT).show()
} else if (switchHiddenVolume.isChecked && currentVolumeName.contains("/")){
Toast.makeText(this, R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
} else {
currentVolumePath = if (switchHiddenVolume.isChecked) {
PathUtils.pathJoin(filesDir.path, currentVolumeName)
} else {
currentVolumeName
}
callback()
}
}
}

View File

@ -0,0 +1,111 @@
package sushi.hardcore.droidfs
import android.os.Parcel
import android.os.Parcelable
import sushi.hardcore.droidfs.filesystems.CryfsVolume
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
import java.io.FileInputStream
import java.util.UUID
class VolumeData(
val uuid: String,
val name: String,
val isHidden: Boolean = false,
val type: Byte,
var encryptedHash: ByteArray? = null,
var iv: ByteArray? = null
) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readString()!!,
parcel.readByte() != 0.toByte(),
parcel.readByte(),
parcel.createByteArray(),
parcel.createByteArray()
)
val shortName: String by lazy {
File(name).name
}
fun getFullPath(filesDir: String) = getFullPath(name, isHidden, filesDir)
fun canRead(filesDir: String): Boolean {
val volumePath = getFullPath(filesDir)
if (!File(volumePath).canRead()) {
return false
}
val configFile = when (type) {
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE -> PathUtils.pathJoin(volumePath, GocryptfsVolume.CONFIG_FILE_NAME)
EncryptedVolume.CRYFS_VOLUME_TYPE -> PathUtils.pathJoin(volumePath, CryfsVolume.CONFIG_FILE_NAME)
else -> return false
}
var success = true
try {
with (FileInputStream(configFile)) {
read()
close()
}
} catch (e: Exception) {
success = false
}
return success
}
fun canWrite(filesDir: String): Boolean {
return File(getFullPath(filesDir)).canWrite()
}
override fun describeContents(): Int {
return 0
}
override fun writeToParcel(dest: Parcel, flags: Int) {
with (dest) {
writeString(uuid)
writeString(name)
writeByte(if (isHidden) 1 else 0)
writeByte(type)
writeByteArray(encryptedHash)
writeByteArray(iv)
}
}
override fun equals(other: Any?): Boolean {
if (other !is VolumeData) {
return false
}
return other.uuid == uuid || (other.name == name && other.isHidden == isHidden)
}
override fun hashCode(): Int {
return name.hashCode()+isHidden.hashCode()
}
companion object {
const val VOLUMES_DIRECTORY = "volumes"
@JvmField
val CREATOR = object : Parcelable.Creator<VolumeData> {
override fun createFromParcel(parcel: Parcel) = VolumeData(parcel)
override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size)
}
fun newUuid(): String = UUID.randomUUID().toString()
fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name)
}
fun getFullPath(name: String, isHidden: Boolean, filesDir: String): String {
return if (isHidden)
getHiddenVolumeFullPath(filesDir, name)
else
name
}
}
}

View File

@ -2,71 +2,223 @@ package sushi.hardcore.droidfs
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import android.util.Log
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
class VolumeDatabase(context: Context): SQLiteOpenHelper(context, class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 6) {
ConstValues.volumeDatabaseName, null, 3) {
companion object { companion object {
const val TABLE_NAME = "Volumes" private const val TAG = "VolumeDatabase"
const val COLUMN_NAME = "name" private const val TABLE_NAME = "Volumes"
const val COLUMN_HIDDEN = "hidden" private const val COLUMN_UUID = "uuid"
const val COLUMN_HASH = "hash" private const val COLUMN_NAME = "name"
const val COLUMN_IV = "iv" private const val COLUMN_HIDDEN = "hidden"
private const val COLUMN_TYPE = "type"
private const val COLUMN_HASH = "hash"
private const val COLUMN_IV = "iv"
}
private fun contentValuesFromVolume(volume: Volume): ContentValues { private fun createTable(db: SQLiteDatabase) =
val contentValues = ContentValues() db.execSQL(
contentValues.put(COLUMN_NAME, volume.name) "CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
contentValues.put(COLUMN_HIDDEN, volume.isHidden) "$COLUMN_UUID TEXT PRIMARY KEY," +
contentValues.put(COLUMN_HASH, volume.hash) "$COLUMN_NAME TEXT," +
contentValues.put(COLUMN_IV, volume.iv) "$COLUMN_HIDDEN SHORT," +
return contentValues "$COLUMN_TYPE BLOB," +
"$COLUMN_HASH BLOB," +
"$COLUMN_IV BLOB" +
");"
)
override fun onCreate(db: SQLiteDatabase) {
createTable(db)
File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()
}
override fun onOpen(db: SQLiteDatabase) {
//check if database has been corrupted by v2.1.1
val cursor = db.rawQuery("SELECT * FROM $TABLE_NAME WHERE $COLUMN_TYPE IS NULL;", null)
if (cursor.count > 0) {
Log.w(TAG, "Found ${cursor.count} corrupted volumes")
while (cursor.moveToNext()) {
// fix columns left shift
val uuid = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)+5)
val name = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)-1)
val isHidden = cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)-1) == 1.toShort()
val type = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE)-1)[0]
val hash = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)-1)
val iv = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_IV)-1)
if (db.delete(TABLE_NAME, "$COLUMN_IV=?", arrayOf(uuid)) < 1) {
Log.e(TAG, "Failed to remove volume $name")
}
if (db.insert(TABLE_NAME, null, ContentValues().apply {
put(COLUMN_UUID, uuid)
put(COLUMN_NAME, name)
put(COLUMN_HIDDEN, isHidden)
put(COLUMN_TYPE, byteArrayOf(type))
put(COLUMN_HASH, hash)
put(COLUMN_IV, iv)
}) < 0) {
Log.e(TAG, "Failed to insert volume $name")
}
}
}
cursor.close()
}
private fun getNewVolumePath(volumeName: String): File {
return File(
VolumeData.getFullPath(volumeName, true, context.filesDir.path)
).canonicalFile
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion == 3) {
// Adding type column and set it to GOCRYPTFS_VOLUME_TYPE for all existing volumes
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $COLUMN_TYPE BLOB;")
db.update(TABLE_NAME, ContentValues().apply {
put(COLUMN_TYPE, byteArrayOf(EncryptedVolume.GOCRYPTFS_VOLUME_TYPE))
}, null, null)
// Moving registered hidden volumes to the "volumes" directory
if (File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()) {
val cursor = db.query(
TABLE_NAME,
arrayOf(COLUMN_NAME),
"$COLUMN_HIDDEN=?",
arrayOf("1"),
null,
null,
null
)
while (cursor.moveToNext()) {
val volumeName = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME))
val success = File(
PathUtils.pathJoin(
context.filesDir.path,
volumeName
)
).renameTo(getNewVolumePath(volumeName))
if (!success) {
Log.e(TAG, "Failed to move $volumeName")
}
}
cursor.close()
} else {
Log.e(TAG, "Volumes directory creation failed while upgrading")
}
}
// Moving unregistered hidden volumes to the "volumes" directory
File(context.filesDir.path).listFiles()?.let {
for (i in it) {
if (i.isDirectory && i.name != Constants.CRYFS_LOCAL_STATE_DIR && i.name != VolumeData.VOLUMES_DIRECTORY) {
if (EncryptedVolume.getVolumeType(i.path) != (-1).toByte()) {
if (!i.renameTo(getNewVolumePath(i.name))) {
Log.e(TAG, "Failed to move "+i.name)
}
}
}
}
}
if (oldVersion < 6) {
val cursor = db.rawQuery("SELECT $COLUMN_NAME FROM $TABLE_NAME;", null)
val volumeNames = arrayOfNulls<String>(cursor.count)
var i = 0
while (cursor.moveToNext()) {
volumeNames[i++] = cursor.getString(0)
}
cursor.close()
if (volumeNames.isEmpty()) {
db.execSQL("DROP TABLE $TABLE_NAME;")
createTable(db)
} else {
db.execSQL("ALTER TABLE $TABLE_NAME RENAME TO OLD;")
createTable(db)
val uuidsValues = volumeNames.indices.joinToString(", ") { "('${VolumeData.newUuid()}', ?)" }
// add uuids to old data
db.execSQL(
"INSERT INTO $TABLE_NAME " +
"WITH uuids($COLUMN_UUID, $COLUMN_NAME) AS (VALUES $uuidsValues) " +
"SELECT $COLUMN_UUID, OLD.$COLUMN_NAME, $COLUMN_HIDDEN, $COLUMN_TYPE, $COLUMN_HASH, $COLUMN_IV " +
"FROM OLD JOIN uuids ON OLD.name = uuids.name;",
volumeNames
)
db.execSQL("DROP TABLE OLD;")
}
} }
} }
override fun onCreate(db: SQLiteDatabase) {
db.execSQL( private fun extractVolumeData(cursor: Cursor): VolumeData {
"CREATE TABLE IF NOT EXISTS $TABLE_NAME ($COLUMN_NAME TEXT PRIMARY KEY, $COLUMN_HIDDEN SHORT, $COLUMN_HASH BLOB, $COLUMN_IV BLOB);" return VolumeData(
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)),
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)),
cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(),
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE))[0],
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)),
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_IV))
) )
} }
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {} private fun getVolumeCursor(volumeName: String, isHidden: Boolean): Cursor {
return readableDatabase.query(
TABLE_NAME, null,
"$COLUMN_NAME=? AND $COLUMN_HIDDEN=?",
arrayOf(volumeName, (if (isHidden) 1 else 0).toString()),
null, null, null
)
}
fun isVolumeSaved(volumeName: String): Boolean { fun getVolume(volumeName: String, isHidden: Boolean): VolumeData? {
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null) val cursor = getVolumeCursor(volumeName, isHidden)
val volumeData = if (cursor.moveToNext()) {
extractVolumeData(cursor)
} else {
null
}
cursor.close()
return volumeData
}
fun isVolumeSaved(volumeName: String, isHidden: Boolean): Boolean {
val cursor = getVolumeCursor(volumeName, isHidden)
val result = cursor.count > 0 val result = cursor.count > 0
cursor.close() cursor.close()
return result return result
} }
fun saveVolume(volume: Volume): Boolean { fun saveVolume(volume: VolumeData): Boolean {
if (!isVolumeSaved(volume.name)){ if (!isVolumeSaved(volume.name, volume.isHidden)) {
return (writableDatabase.insert(TABLE_NAME, null, contentValuesFromVolume(volume)) == 0.toLong()) return (writableDatabase.insert(TABLE_NAME, null, ContentValues().apply {
put(COLUMN_UUID, volume.uuid)
put(COLUMN_NAME, volume.name)
put(COLUMN_HIDDEN, volume.isHidden)
put(COLUMN_TYPE, byteArrayOf(volume.type))
put(COLUMN_HASH, volume.encryptedHash)
put(COLUMN_IV, volume.iv)
}) >= 0.toLong())
} }
return false return false
} }
fun getVolumes(): List<Volume> { fun getVolumes(): List<VolumeData> {
val list: MutableList<Volume> = ArrayList() val list: MutableList<VolumeData> = ArrayList()
val cursor = readableDatabase.rawQuery("SELECT * FROM $TABLE_NAME", null) val cursor = readableDatabase.rawQuery("SELECT * FROM $TABLE_NAME", null)
while (cursor.moveToNext()){ while (cursor.moveToNext()){
list.add( list.add(extractVolumeData(cursor))
Volume(
cursor.getString(cursor.getColumnIndex(COLUMN_NAME)),
cursor.getShort(cursor.getColumnIndex(COLUMN_HIDDEN)) == 1.toShort(),
cursor.getBlob(cursor.getColumnIndex(COLUMN_HASH)),
cursor.getBlob(cursor.getColumnIndex(COLUMN_IV))
)
)
} }
cursor.close() cursor.close()
return list return list
} }
fun isHashSaved(volumeName: String): Boolean { fun isHashSaved(volume: VolumeData): Boolean {
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME, COLUMN_HASH), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null) val cursor = readableDatabase.rawQuery("SELECT $COLUMN_HASH FROM $TABLE_NAME WHERE $COLUMN_UUID=?", arrayOf(volume.uuid))
var isHashSaved = false var isHashSaved = false
if (cursor.moveToNext()){ if (cursor.moveToNext()) {
if (cursor.getBlob(cursor.getColumnIndex(COLUMN_HASH)) != null){ if (cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)) != null) {
isHashSaved = true isHashSaved = true
} }
} }
@ -74,23 +226,34 @@ class VolumeDatabase(context: Context): SQLiteOpenHelper(context,
return isHashSaved return isHashSaved
} }
fun addHash(volume: Volume): Boolean { fun addHash(volume: VolumeData): Boolean {
return writableDatabase.update(TABLE_NAME, contentValuesFromVolume(volume), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0 return writableDatabase.update(TABLE_NAME, ContentValues().apply {
put(COLUMN_HASH, volume.encryptedHash)
put(COLUMN_IV, volume.iv)
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
} }
fun removeHash(volume: Volume): Boolean { fun removeHash(volume: VolumeData): Boolean {
return writableDatabase.update( return writableDatabase.update(
TABLE_NAME, contentValuesFromVolume( TABLE_NAME,
Volume( ContentValues().apply {
volume.name, put(COLUMN_HASH, null as ByteArray?)
volume.isHidden, put(COLUMN_IV, null as ByteArray?)
null, }, "$COLUMN_UUID=?", arrayOf(volume.uuid)
null ) > 0
)
), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
} }
fun removeVolume(volume: Volume): Boolean { fun renameVolume(volume: VolumeData, newName: String): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_NAME=?", arrayOf(volume.name)) > 0 return writableDatabase.update(
TABLE_NAME,
ContentValues().apply {
put(COLUMN_NAME, newName)
},
"$COLUMN_UUID=?", arrayOf(volume.uuid)
) > 0
}
fun removeVolume(volume: VolumeData): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
} }
} }

View File

@ -0,0 +1,75 @@
package sushi.hardcore.droidfs
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Observable
class VolumeManager(private val context: Context): Observable<VolumeManager.Observer>() {
interface Observer {
fun onVolumeStateChanged(volume: VolumeData) {}
fun onAllVolumesClosed() {}
}
private var id = 0
private val volumes = HashMap<Int, EncryptedVolume>()
private val volumesData = HashMap<VolumeData, Int>()
private val scopes = HashMap<Int, CoroutineScope>()
fun insert(volume: EncryptedVolume, data: VolumeData): Int {
volumes[id] = volume
volumesData[data] = id
observers.forEach { it.onVolumeStateChanged(data) }
VolumeProvider.notifyRootsChanged(context)
return id++
}
fun isOpen(volume: VolumeData): Boolean {
return volumesData.containsKey(volume)
}
fun getVolumeId(volume: VolumeData): Int? {
return volumesData[volume]
}
fun getVolume(id: Int): EncryptedVolume? {
return volumes[id]
}
fun listVolumes(): List<Pair<Int, VolumeData>> {
return volumesData.map { (data, id) -> Pair(id, data) }
}
fun getVolumeCount() = volumes.size
fun getCoroutineScope(volumeId: Int): CoroutineScope {
return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it }
}
fun closeVolume(id: Int) {
volumes.remove(id)?.let { volume ->
scopes[id]?.cancel()
volume.closeVolume()
volumesData.filter { it.value == id }.forEach { entry ->
volumesData.remove(entry.key)
observers.forEach { it.onVolumeStateChanged(entry.key) }
}
VolumeProvider.notifyRootsChanged(context)
}
}
fun closeAll() {
volumes.forEach {
scopes[it.key]?.cancel()
it.value.closeVolume()
}
volumes.clear()
volumesData.clear()
observers.forEach { it.onAllVolumesClosed() }
VolumeProvider.notifyRootsChanged(context)
}
}

View File

@ -0,0 +1,102 @@
package sushi.hardcore.droidfs
import android.app.Application
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.util.AndroidUtils
class VolumeManagerApp : Application(), DefaultLifecycleObserver {
companion object {
const val ACTION_CLOSE_ALL_VOLUMES = "close_all"
}
private val closingServiceIntent by lazy {
Intent(this, ClosingService::class.java)
}
private val keepAliveServiceStartIntent by lazy {
Intent(this, KeepAliveService::class.java).apply {
action = KeepAliveService.ACTION_START
}.putExtra(
"notification", KeepAliveService.NotificationDetails(
"KeepAlive",
getString(R.string.keep_alive_notification_title),
getString(R.string.keep_alive_notification_text),
KeepAliveService.NotificationDetails.NotificationAction(
R.drawable.icon_lock,
getString(R.string.close_all),
ACTION_CLOSE_ALL_VOLUMES,
)
)
)
}
private val usfBackgroundDelegate = AndroidUtils.LiveBooleanPreference("usf_background", false) { _ ->
updateServicesStates()
}
private val usfBackground by usfBackgroundDelegate
private val usfKeepOpenDelegate = AndroidUtils.LiveBooleanPreference("usf_keep_open", false) { _ ->
updateServicesStates()
}
private val usfKeepOpen by usfKeepOpenDelegate
var isExporting = false
var isStartingExternalApp = false
val volumeManager = VolumeManager(this).also {
it.observe(object : VolumeManager.Observer {
override fun onVolumeStateChanged(volume: VolumeData) {
updateServicesStates()
}
override fun onAllVolumesClosed() {
stopKeepAliveService()
// closingService should not be running when this callback is triggered
}
})
}
override fun onCreate() {
super<Application>.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
AndroidUtils.LiveBooleanPreference.init(this, usfBackgroundDelegate, usfKeepOpenDelegate)
}
fun updateServicesStates() {
if (usfBackground && volumeManager.getVolumeCount() > 0) {
if (usfKeepOpen) {
stopService(closingServiceIntent)
if (!AndroidUtils.isServiceRunning(this@VolumeManagerApp, KeepAliveService::class.java)) {
ContextCompat.startForegroundService(this, keepAliveServiceStartIntent)
}
} else {
stopKeepAliveService()
if (!AndroidUtils.isServiceRunning(this@VolumeManagerApp, ClosingService::class.java)) {
startService(closingServiceIntent)
}
}
} else {
stopService(closingServiceIntent)
stopKeepAliveService()
}
}
private fun stopKeepAliveService() {
stopService(Intent(this, KeepAliveService::class.java))
}
override fun onResume(owner: LifecycleOwner) {
isStartingExternalApp = false
}
override fun onStop(owner: LifecycleOwner) {
if (!isStartingExternalApp) {
if (!usfBackground) {
volumeManager.closeAll()
}
if (!usfBackground || !isExporting) {
TemporaryFileProvider.instance.wipe()
}
}
}
}

View File

@ -0,0 +1,227 @@
package sushi.hardcore.droidfs
import android.annotation.SuppressLint
import android.os.Build
import android.text.InputType
import android.view.View
import android.view.WindowManager
import android.widget.Toast
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.util.*
class VolumeOpener(
private val activity: FragmentActivity,
) {
interface VolumeOpenerCallbacks {
fun onHashStorageReset() {}
fun onVolumeOpened(id: Int)
}
private val volumeDatabase = VolumeDatabase(activity)
private var fingerprintProtector: FingerprintProtector? = null
private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(activity)
private val theme = (activity as BaseActivity).theme
var defaultVolumeName: String? = sharedPrefs.getString(DEFAULT_VOLUME_KEY, null)
private var dialogBinding: DialogOpenVolumeBinding? = null
private val volumeManager = (activity.application as VolumeManagerApp).volumeManager
init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
fingerprintProtector = FingerprintProtector.new(activity, theme, volumeDatabase)
}
}
private fun getErrorMsg(result: EncryptedVolume.InitResult): String {
return if (result.errorStringId == 0) {
activity.getString(R.string.unknown_error_code, result.errorCode)
} else {
activity.getString(result.errorStringId)
}
}
@SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23
fun openVolume(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks) {
val volumeId = volumeManager.getVolumeId(volume)
if (volumeId == null) {
if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE && BuildConfig.GOCRYPTFS_DISABLED) {
Toast.makeText(activity, R.string.gocryptfs_disabled, Toast.LENGTH_SHORT).show()
return
} else if (volume.type == EncryptedVolume.CRYFS_VOLUME_TYPE && BuildConfig.CRYFS_DISABLED) {
Toast.makeText(activity, R.string.cryfs_disabled, Toast.LENGTH_SHORT).show()
return
}
var askForPassword = true
fingerprintProtector?.let { fingerprintProtector ->
volume.encryptedHash?.let { encryptedHash ->
volume.iv?.let { iv ->
askForPassword = false
fingerprintProtector.listener = object : FingerprintProtector.Listener {
override fun onHashStorageReset() {
callbacks.onHashStorageReset()
}
override fun onPasswordHashDecrypted(hash: ByteArray) {
object : LoadingTask<EncryptedVolume.InitResult>(activity, theme, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume.InitResult {
val result = EncryptedVolume.init(volume, activity.filesDir.path, null, hash, null)
Arrays.fill(hash, 0)
return result
}
}.startTask(activity.lifecycleScope) { result ->
val encryptedVolume = result.volume
if (encryptedVolume == null) {
CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.open_volume_failed)
.setMessage(getErrorMsg(result))
.setPositiveButton(R.string.ok, null)
.show()
} else {
callbacks.onVolumeOpened(volumeManager.insert(encryptedVolume, volume))
}
}
}
override fun onPasswordHashSaved() {}
override fun onFailed(pending: Boolean) {
if (!pending && sharedPrefs.getBoolean("passwordFallback", true)) {
askForPassword(volume, isVolumeSaved, callbacks)
}
}
}
fingerprintProtector.loadPasswordHash(volume.shortName, encryptedHash, iv)
}
}
}
if (askForPassword) {
askForPassword(volume, isVolumeSaved, callbacks)
}
} else {
callbacks.onVolumeOpened(volumeId)
}
}
fun wipeSensitive() {
dialogBinding?.editPassword?.text?.clear()
}
private fun onPasswordSubmitted(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks) {
if (dialogBinding!!.checkboxDefaultOpen.isChecked xor (defaultVolumeName == volume.name)) {
with (sharedPrefs.edit()) {
defaultVolumeName = if (dialogBinding!!.checkboxDefaultOpen.isChecked) {
putString(DEFAULT_VOLUME_KEY, volume.name)
volume.name
} else {
remove(DEFAULT_VOLUME_KEY)
null
}
apply()
}
}
val password = UIUtils.encodeEditTextContent(dialogBinding!!.editPassword)
val savePasswordHash = dialogBinding!!.checkboxSavePassword.isChecked
dialogBinding = null
// openVolumeWithPassword is responsible for wiping the password
openVolumeWithPassword(
volume,
password,
isVolumeSaved,
savePasswordHash,
callbacks,
)
}
private fun askForPassword(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks, savePasswordHash: Boolean = false) {
dialogBinding = DialogOpenVolumeBinding.inflate(activity.layoutInflater)
if (isVolumeSaved) {
if (!sharedPrefs.getBoolean("usf_fingerprint", false) || fingerprintProtector == null || volume.encryptedHash != null) {
dialogBinding!!.checkboxSavePassword.visibility = View.GONE
} else {
dialogBinding!!.checkboxSavePassword.isChecked = savePasswordHash
}
dialogBinding!!.checkboxDefaultOpen.isChecked = defaultVolumeName == volume.name
} else {
dialogBinding!!.checkboxSavePassword.visibility = View.GONE
dialogBinding!!.checkboxDefaultOpen.visibility = View.GONE
}
val dialog = CustomAlertDialogBuilder(activity, theme)
.setTitle(activity.getString(R.string.open_dialog_title, volume.shortName))
.setView(dialogBinding!!.root)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.open) { _, _ ->
onPasswordSubmitted(volume, isVolumeSaved, callbacks)
}
.create()
dialogBinding!!.editPassword.apply {
setOnEditorActionListener { _, _, _ ->
dialog.dismiss()
onPasswordSubmitted(volume, isVolumeSaved, callbacks)
true
}
if (sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false)) {
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
}
}
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
dialog.show()
}
private fun openVolumeWithPassword(volume: VolumeData, password: ByteArray, isVolumeSaved: Boolean, savePasswordHash: Boolean, callbacks: VolumeOpenerCallbacks) {
val returnedHash: ObjRef<ByteArray?>? = if (savePasswordHash) {
ObjRef(null)
} else {
null
}
object : LoadingTask<EncryptedVolume.InitResult>(activity, theme, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume.InitResult {
val result = EncryptedVolume.init(volume, activity.filesDir.path, password, null, returnedHash)
Arrays.fill(password, 0)
return result
}
}.startTask(activity.lifecycleScope) { result ->
val encryptedVolume = result.volume
if (encryptedVolume == null) {
CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.open_volume_failed)
.setMessage(getErrorMsg(result))
.setPositiveButton(R.string.ok) { _, _ ->
if (result.worthRetry) {
askForPassword(volume, isVolumeSaved, callbacks, savePasswordHash)
}
}
.show()
} else {
val fingerprintProtector = fingerprintProtector
@SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23
if (savePasswordHash && returnedHash != null && fingerprintProtector != null) {
fingerprintProtector.listener = object : FingerprintProtector.Listener {
override fun onHashStorageReset() {
callbacks.onHashStorageReset()
}
override fun onPasswordHashDecrypted(hash: ByteArray) {}
override fun onPasswordHashSaved() {
Arrays.fill(returnedHash.value!!, 0)
callbacks.onVolumeOpened(volumeManager.insert(encryptedVolume, volume))
}
private var isClosed = false
override fun onFailed(pending: Boolean) {
if (!isClosed) {
encryptedVolume.closeVolume()
isClosed = true
}
Arrays.fill(returnedHash.value!!, 0)
}
}
fingerprintProtector.savePasswordHash(volume, returnedHash.value!!)
} else {
callbacks.onVolumeOpened(volumeManager.insert(encryptedVolume, volume))
}
}
}
}
}

View File

@ -1,33 +0,0 @@
package sushi.hardcore.droidfs.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.CheckedTextView
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.ThemeColor
class DialogSingleChoiceAdapter(private val context: Context, private val entries: List<String>): BaseAdapter() {
private val inflater: LayoutInflater = LayoutInflater.from(context)
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val view: View = convertView ?: inflater.inflate(R.layout.adapter_colored_dialog_single_choice, parent, false)
val checkedTextView = view.findViewById<CheckedTextView>(android.R.id.text1)
checkedTextView.text = getItem(position)
val typedArray = context.theme.obtainStyledAttributes(arrayOf(android.R.attr.listChoiceIndicatorSingle).toIntArray())
val drawable = typedArray.getDrawable(0)
typedArray.recycle()
drawable?.setTint(ThemeColor.getThemeColor(context))
checkedTextView.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null)
return view
}
override fun getItem(position: Int): String {
return entries[position]
}
override fun getItemId(position: Int): Long { return 0 }
override fun getCount(): Int { return entries.size }
}

View File

@ -1,119 +1,222 @@
package sushi.hardcore.droidfs.adapters package sushi.hardcore.droidfs.adapters
import android.content.Context import android.annotation.SuppressLint
import android.graphics.Color import android.graphics.Bitmap
import android.graphics.PorterDuff import android.util.LruCache
import android.graphics.PorterDuffColorFilter
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.appcompat.app.AppCompatActivity
import sushi.hardcore.droidfs.ConstValues.Companion.getAssociatedDrawable import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.*
import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.ThumbnailsLoader
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.ThemeColor
import java.text.DateFormat import java.text.DateFormat
import java.util.* import java.util.*
class ExplorerElementAdapter(private val context: Context) : BaseAdapter() { class ExplorerElementAdapter(
private val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.getDefault()) val activity: AppCompatActivity,
private var explorerElements = listOf<ExplorerElement>() val encryptedVolume: EncryptedVolume?,
private val inflater: LayoutInflater = LayoutInflater.from(context) private val listener: Listener,
val selectedItems: MutableList<Int> = ArrayList() thumbnailMaxSize: Long,
private val themeColor = ThemeColor.getThemeColor(context) ) : SelectableAdapter<ExplorerElement>(listener::onSelectionChanged) {
override fun getCount(): Int { val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.getDefault())
return explorerElements.size var explorerElements = listOf<ExplorerElement>()
@SuppressLint("NotifyDataSetChanged")
set(value) {
field = value
thumbnailsCache?.evictAll()
notifyDataSetChanged()
}
var isUsingListLayout = true
private var thumbnailsLoader: ThumbnailsLoader? = null
private var thumbnailsCache: LruCache<String, Bitmap>? = null
var loadThumbnails = true
init {
if (encryptedVolume != null) {
thumbnailsLoader = ThumbnailsLoader(activity, encryptedVolume, thumbnailMaxSize, activity.lifecycleScope).apply {
initialize()
}
thumbnailsCache = object : LruCache<String, Bitmap>((Runtime.getRuntime().maxMemory() / 4).toInt()) {
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
}
}
} }
override fun getItem(position: Int): ExplorerElement { interface Listener {
return explorerElements[position] fun onSelectionChanged(size: Int)
fun onExplorerElementClick(position: Int)
fun onExplorerElementLongClick(position: Int)
} }
override fun getItemId(position: Int): Long { override fun getItems(): List<ExplorerElement> {
return 0 return explorerElements
} }
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun toggleSelection(position: Int): Boolean {
val view: View = convertView ?: inflater.inflate(R.layout.adapter_explorer_element, parent, false) return if (!explorerElements[position].isParentFolder) {
val currentElement = getItem(position) super.toggleSelection(position)
val textElementName = view.findViewById<TextView>(R.id.text_element_name)
textElementName.text = currentElement.name
val textElementMtime = view.findViewById<TextView>(R.id.text_element_mtime)
val textElementSize = view.findViewById<TextView>(R.id.text_element_size)
if (!currentElement.isParentFolder){
textElementSize.text = PathUtils.formatSize(currentElement.size)
} else { } else {
textElementSize.text = "" false
} }
var drawableId = R.drawable.icon_folder
when {
currentElement.isDirectory -> {
textElementMtime.text = dateFormat.format(currentElement.mTime)
}
currentElement.isParentFolder -> {
textElementMtime.setText(R.string.parent_folder)
}
else -> {
textElementMtime.text = dateFormat.format(currentElement.mTime)
drawableId = getAssociatedDrawable(currentElement.name)
}
}
val elementIcon = view.findViewById<ImageView>(R.id.icon_element)
val icon = ContextCompat.getDrawable(context, drawableId)
icon?.colorFilter = PorterDuffColorFilter(themeColor, PorterDuff.Mode.SRC_IN)
elementIcon.setImageDrawable(icon)
if (selectedItems.contains(position)) {
view.setBackgroundColor(ContextCompat.getColor(context, R.color.item_selected))
} else {
view.setBackgroundColor(Color.alpha(0))
}
return view
} }
fun onItemClick(position: Int) { override fun onItemClick(position: Int): Boolean {
if (selectedItems.isNotEmpty()) { listener.onExplorerElementClick(position)
if (!explorerElements[position].isParentFolder) { return super.onItemClick(position)
if (selectedItems.contains(position)) { }
selectedItems.remove(position)
} else { override fun onItemLongClick(position: Int): Boolean {
selectedItems.add(position) listener.onExplorerElementLongClick(position)
return super.onItemLongClick(position)
}
override fun isSelectable(position: Int): Boolean {
return !explorerElements[position].isParentFolder
}
open class ExplorerElementViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val textElementName by lazy {
itemView.findViewById<TextView>(R.id.text_element_name)
}
protected val textElementSize: TextView by lazy {
itemView.findViewById(R.id.text_element_size)
}
protected val textElementMtime: TextView by lazy {
itemView.findViewById(R.id.text_element_mtime)
}
protected val icon: ImageView by lazy {
itemView.findViewById(R.id.icon_element)
}
private val selectableContainer: LinearLayout by lazy {
itemView.findViewById(R.id.selectable_container)
}
open fun bind(explorerElement: ExplorerElement, position: Int) {
textElementName.text = explorerElement.name
(bindingAdapter as ExplorerElementAdapter?)?.setSelectable(selectableContainer, itemView, position)
}
}
open class RegularElementViewHolder(itemView: View) : ExplorerElementViewHolder(itemView) {
open fun bind(explorerElement: ExplorerElement, position: Int, isSelected: Boolean) {
super.bind(explorerElement, position)
textElementSize.text = PathUtils.formatSize(explorerElement.stat.size)
(bindingAdapter as ExplorerElementAdapter?)?.let {
textElementMtime.text = it.dateFormat.format(explorerElement.stat.mTime)
}
}
}
class FileViewHolder(itemView: View) : RegularElementViewHolder(itemView) {
private var task = -1
fun cancelThumbnailLoading(adapter: ExplorerElementAdapter) {
if (task != -1) {
adapter.thumbnailsLoader?.cancel(task)
}
}
private fun setThumbnailOrDefaultIcon(fullPath: String, defaultIconId: Int) {
var setDefaultIcon = true
(bindingAdapter as ExplorerElementAdapter?)?.let { adapter ->
adapter.thumbnailsCache?.let {
val thumbnail = it.get(fullPath)
if (thumbnail != null) {
icon.setImageBitmap(thumbnail)
setDefaultIcon = false
} else if (adapter.loadThumbnails) {
task = adapter.thumbnailsLoader!!.loadAsync(fullPath, icon) { resource ->
val bitmap = resource.toBitmap()
adapter.thumbnailsCache!!.put(fullPath, bitmap.copy(bitmap.config, true))
}
}
} }
notifyDataSetChanged() }
if (setDefaultIcon) {
icon.setImageResource(defaultIconId)
}
}
override fun bind(explorerElement: ExplorerElement, position: Int, isSelected: Boolean) {
super.bind(explorerElement, position, isSelected)
when {
FileTypes.isImage(explorerElement.name) -> {
setThumbnailOrDefaultIcon(explorerElement.fullPath, R.drawable.icon_file_image)
}
FileTypes.isVideo(explorerElement.name) -> {
setThumbnailOrDefaultIcon(explorerElement.fullPath, R.drawable.icon_file_video)
}
else -> icon.setImageResource(
when {
FileTypes.isText(explorerElement.name) -> R.drawable.icon_file_text
FileTypes.isPDF(explorerElement.name) -> R.drawable.icon_file_pdf
FileTypes.isAudio(explorerElement.name) -> R.drawable.icon_file_audio
else -> R.drawable.icon_file_unknown
}
)
} }
} }
} }
fun onItemLongClick(position: Int) { class DirectoryViewHolder(itemView: View) : RegularElementViewHolder(itemView) {
if (!explorerElements[position].isParentFolder) { override fun bind(explorerElement: ExplorerElement, position: Int, isSelected: Boolean) {
if (!selectedItems.contains(position)) { super.bind(explorerElement, position, isSelected)
selectedItems.add(position) icon.setImageResource(R.drawable.icon_folder)
}
}
class ParentFolderViewHolder(itemView: View): ExplorerElementViewHolder(itemView) {
override fun bind(explorerElement: ExplorerElement, position: Int) {
super.bind(explorerElement, position)
textElementSize.text = ""
textElementMtime.setText(R.string.parent_folder)
icon.setImageResource(R.drawable.icon_folder)
}
}
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
if (holder is FileViewHolder) {
holder.cancelThumbnailLoading(this)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view = activity.layoutInflater.inflate(
if (isUsingListLayout) {
R.layout.adapter_explorer_element_list
} else { } else {
selectedItems.remove(position) R.layout.adapter_explorer_element_grid
} }, parent, false
notifyDataSetChanged() )
return when (viewType) {
Stat.S_IFREG -> FileViewHolder(view)
Stat.S_IFDIR -> DirectoryViewHolder(view)
Stat.PARENT_FOLDER_TYPE -> ParentFolderViewHolder(view)
else -> throw IllegalArgumentException()
} }
} }
fun selectAll() { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
for (i in explorerElements.indices) { val element = explorerElements[position]
if (!selectedItems.contains(i) && !explorerElements[i].isParentFolder) { if (element.isParentFolder) {
selectedItems.add(i) (holder as ParentFolderViewHolder).bind(element, position)
} } else {
(holder as RegularElementViewHolder).bind(element, position, selectedItems.contains(position))
} }
notifyDataSetChanged()
} }
fun unSelectAll() { override fun getItemViewType(position: Int): Int {
selectedItems.clear() return explorerElements[position].stat.type
notifyDataSetChanged()
}
fun setExplorerElements(explorer_elements: List<ExplorerElement>) {
this.explorerElements = explorer_elements
unSelectAll()
} }
} }

View File

@ -5,9 +5,10 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.BaseAdapter import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.content.ContextCompat
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.ColoredImageView
open class IconTextDialogAdapter(private val context: Context): BaseAdapter() { open class IconTextDialogAdapter(private val context: Context): BaseAdapter() {
private val inflater: LayoutInflater = LayoutInflater.from(context) private val inflater: LayoutInflater = LayoutInflater.from(context)
@ -17,8 +18,8 @@ open class IconTextDialogAdapter(private val context: Context): BaseAdapter() {
val view: View = convertView ?: inflater.inflate(R.layout.adapter_dialog_icon_text, parent, false) val view: View = convertView ?: inflater.inflate(R.layout.adapter_dialog_icon_text, parent, false)
val text = view.findViewById<TextView>(R.id.text) val text = view.findViewById<TextView>(R.id.text)
text.text = context.getString(items[position][1] as Int) text.text = context.getString(items[position][1] as Int)
val icon = view.findViewById<ColoredImageView>(R.id.icon) val icon = view.findViewById<ImageView>(R.id.icon)
icon.setImageDrawable(context.getDrawable(items[position][2] as Int)) icon.setImageDrawable(ContextCompat.getDrawable(context, items[position][2] as Int))
return view return view
} }

View File

@ -8,6 +8,7 @@ class OpenAsDialogAdapter(context: Context, showOpenWithExternalApp: Boolean) :
listOf("image", R.string.image, R.drawable.icon_file_image), listOf("image", R.string.image, R.drawable.icon_file_image),
listOf("video", R.string.video, R.drawable.icon_file_video), listOf("video", R.string.video, R.drawable.icon_file_video),
listOf("audio", R.string.audio, R.drawable.icon_file_audio), listOf("audio", R.string.audio, R.drawable.icon_file_audio),
listOf("pdf", R.string.pdf_document, R.drawable.icon_file_pdf),
listOf("text", R.string.text, R.drawable.icon_file_text) listOf("text", R.string.text, R.drawable.icon_file_text)
) )
init { init {

View File

@ -1,111 +0,0 @@
package sushi.hardcore.droidfs.adapters
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.TextView
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.Volume
import sushi.hardcore.droidfs.VolumeDatabase
import sushi.hardcore.droidfs.util.WidgetUtil
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.NonScrollableColoredBorderListView
import java.io.File
class SavedVolumesAdapter(private val context: Context, private val volumeDatabase: VolumeDatabase) : BaseAdapter() {
private val inflater: LayoutInflater = LayoutInflater.from(context)
private lateinit var nonScrollableColoredBorderListView: NonScrollableColoredBorderListView
override fun getCount(): Int {
return volumeDatabase.getVolumes().size
}
override fun getItem(position: Int): Volume {
return volumeDatabase.getVolumes()[position]
}
override fun getItemId(position: Int): Long {
return 0
}
private fun deletePasswordHash(volume: Volume){
volumeDatabase.removeHash(volume)
volume.hash = null
volume.iv = null
}
private fun deleteVolumeData(volume: Volume, parent: ViewGroup){
volumeDatabase.removeVolume(volume)
refresh(parent)
}
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
if (!::nonScrollableColoredBorderListView.isInitialized){
nonScrollableColoredBorderListView = parent as NonScrollableColoredBorderListView
}
val view: View = convertView ?: inflater.inflate(R.layout.adapter_saved_volume, parent, false)
val volumeNameTextView = view.findViewById<TextView>(R.id.volume_name_textview)
val currentVolume = getItem(position)
volumeNameTextView.text = currentVolume.name
val deleteImageView = view.findViewById<ImageView>(R.id.delete_imageview)
deleteImageView.setOnClickListener {
val dialog = ColoredAlertDialogBuilder(context)
dialog.setTitle(R.string.warning)
if (currentVolume.isHidden){
if (currentVolume.hash != null) {
dialog.setMessage(R.string.hidden_volume_delete_question_hash)
dialog.setPositiveButton(R.string.password_hash){ _, _ ->
deletePasswordHash(currentVolume)
}
dialog.setNegativeButton(R.string.password_hash_and_path){ _, _ ->
deleteVolumeData(currentVolume, parent)
}
dialog.setNeutralButton(R.string.whole_volume){ _, _ ->
PathUtils.recursiveRemoveDirectory(File(PathUtils.pathJoin(context.filesDir.path, currentVolume.name)))
deleteVolumeData(currentVolume, parent)
}
} else {
dialog.setMessage(R.string.hidden_volume_delete_question)
dialog.setPositiveButton(R.string.path_only){ _, _ ->
deleteVolumeData(currentVolume, parent)
}
dialog.setNegativeButton(R.string.whole_volume){ _, _ ->
PathUtils.recursiveRemoveDirectory(File(PathUtils.pathJoin(context.filesDir.path, currentVolume.name)))
deleteVolumeData(currentVolume, parent)
}
}
} else {
if (currentVolume.hash != null) {
dialog.setMessage(R.string.delete_hash_or_all)
dialog.setNegativeButton(R.string.password_hash_and_path) { _, _ ->
deleteVolumeData(currentVolume, parent)
}
dialog.setPositiveButton(R.string.password_hash) { _, _ ->
deletePasswordHash(currentVolume)
}
} else {
dialog.setMessage(R.string.ask_delete_volume_path)
dialog.setPositiveButton(R.string.ok) {_, _ ->
deleteVolumeData(currentVolume, parent)
}
dialog.setNegativeButton(R.string.cancel, null)
}
}
dialog.show()
}
return view
}
private fun refresh(parent: ViewGroup) {
notifyDataSetChanged()
if (count == 0){
WidgetUtil.hideWithPadding(parent)
} else {
nonScrollableColoredBorderListView.layoutParams.height = nonScrollableColoredBorderListView.computeHeight()
}
}
}

View File

@ -0,0 +1,86 @@
package sushi.hardcore.droidfs.adapters
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import sushi.hardcore.droidfs.R
abstract class SelectableAdapter<T>(private val onSelectionChanged: (Int) -> Unit) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var selectedItems: MutableSet<Int> = HashSet()
protected abstract fun getItems(): List<T>
override fun getItemCount(): Int {
return getItems().size
}
protected open fun toggleSelection(position: Int): Boolean {
val isSelected = if (selectedItems.contains(position)) {
selectedItems.remove(position)
false
} else {
selectedItems.add(position)
true
}
onSelectionChanged(selectedItems.size)
return isSelected
}
protected open fun onItemClick(position: Int): Boolean {
if (selectedItems.isNotEmpty()) {
return toggleSelection(position)
}
return false
}
protected open fun onItemLongClick(position: Int): Boolean {
return toggleSelection(position)
}
protected open fun isSelectable(position: Int): Boolean {
return true
}
fun unselect(position: Int) {
selectedItems.remove(position)
onSelectionChanged(selectedItems.size)
notifyItemChanged(position)
}
fun selectAll() {
for (i in getItems().indices) {
if (!selectedItems.contains(i) && isSelectable(i)) {
selectedItems.add(i)
notifyItemChanged(i)
}
}
onSelectionChanged(selectedItems.size)
}
fun unSelectAll(notifyChange: Boolean) {
if (notifyChange) {
val whatWasSelected = selectedItems
selectedItems = HashSet()
whatWasSelected.forEach {
notifyItemChanged(it)
}
} else {
selectedItems.clear()
}
onSelectionChanged(selectedItems.size)
}
private fun setBackground(rootView: View, isSelected: Boolean) {
rootView.setBackgroundResource(if (isSelected) R.color.itemSelected else 0)
}
protected fun setSelectable(element: View, rootView: View, position: Int) {
element.setOnClickListener {
setBackground(rootView, onItemClick(position))
}
element.setOnLongClickListener {
setBackground(rootView, onItemLongClick(position))
true
}
setBackground(rootView, selectedItems.contains(position))
}
}

View File

@ -0,0 +1,128 @@
package sushi.hardcore.droidfs.adapters
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeData
import sushi.hardcore.droidfs.VolumeDatabase
import sushi.hardcore.droidfs.VolumeManager
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
class VolumeAdapter(
private val context: Context,
private val volumeDatabase: VolumeDatabase,
private val volumeManager: VolumeManager,
private val allowSelection: Boolean,
private val showReadOnly: Boolean,
private val listener: Listener,
) : SelectableAdapter<VolumeData>(listener::onSelectionChanged) {
private val inflater: LayoutInflater = LayoutInflater.from(context)
lateinit var volumes: List<VolumeData>
init {
reloadVolumes()
volumeManager.observe(object : VolumeManager.Observer {
override fun onVolumeStateChanged(volume: VolumeData) {
notifyItemChanged(volumes.indexOf(volume))
}
@SuppressLint("NotifyDataSetChanged")
override fun onAllVolumesClosed() {
notifyDataSetChanged()
}
})
}
interface Listener {
fun onSelectionChanged(size: Int)
fun onVolumeItemClick(volume: VolumeData, position: Int)
fun onVolumeItemLongClick()
}
override fun getItems(): List<VolumeData> {
return volumes
}
private fun reloadVolumes() {
volumes = if (showReadOnly) {
volumeDatabase.getVolumes()
} else {
volumeDatabase.getVolumes().filter { v -> v.canWrite(context.filesDir.path) }
}
}
override fun onItemClick(position: Int): Boolean {
listener.onVolumeItemClick(volumes[position], position)
return if (allowSelection) {
super.onItemClick(position)
} else {
false
}
}
override fun onItemLongClick(position: Int): Boolean {
listener.onVolumeItemLongClick()
return if (allowSelection)
super.onItemLongClick(position)
else
false
}
fun onVolumeDataChanged(position: Int) {
reloadVolumes()
notifyItemChanged(position)
}
@SuppressLint("NotifyDataSetChanged")
fun refresh() {
reloadVolumes()
unSelectAll(false)
notifyDataSetChanged()
}
inner class VolumeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(position: Int) {
val volume = volumes[position]
itemView.findViewById<TextView>(R.id.text_volume_name).text = volume.shortName
itemView.findViewById<TextView>(R.id.text_path).text = if (volume.isHidden)
context.getString(R.string.hidden_volume)
else
volume.name
itemView.findViewById<ImageView>(R.id.icon_unlocked).isVisible = volumeManager.isOpen(volume)
itemView.findViewById<ImageView>(R.id.icon_fingerprint).isVisible = volume.encryptedHash != null
itemView.findViewById<TextView>(R.id.text_info).text = context.getString(
if (volume.canWrite(context.filesDir.path)) {
R.string.volume_type
} else if (volume.canRead(context.filesDir.path)) {
R.string.volume_type_read_only
} else {
R.string.volume_type_inaccessible
},
context.getString(if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) {
R.string.gocryptfs
} else {
R.string.cryfs
})
)
setSelectable(itemView.findViewById<LinearLayout>(R.id.selectable_container), itemView, layoutPosition)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view: View = inflater.inflate(R.layout.adapter_volume, parent, false)
return VolumeViewHolder(view)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
(holder as VolumeViewHolder).bind(position)
}
}

View File

@ -0,0 +1,16 @@
package sushi.hardcore.droidfs.add_volume
import sushi.hardcore.droidfs.R
enum class Action {
OPEN,
ADD,
CREATE,
;
fun getStringResId() = when (this) {
OPEN -> R.string.open
ADD -> R.string.add_volume
CREATE -> R.string.create_volume
}
}

View File

@ -0,0 +1,99 @@
package sushi.hardcore.droidfs.add_volume
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.addCallback
import sushi.hardcore.droidfs.*
import sushi.hardcore.droidfs.databinding.ActivityAddVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerRouter
class AddVolumeActivity: BaseActivity() {
companion object {
const val RESULT_USER_BACK = 10
}
private lateinit var binding: ActivityAddVolumeBinding
private lateinit var explorerRouter: ExplorerRouter
private lateinit var volumeOpener: VolumeOpener
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAddVolumeBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
explorerRouter = ExplorerRouter(this, intent)
volumeOpener = VolumeOpener(this)
if (savedInstanceState == null) {
supportFragmentManager
.beginTransaction()
.add(
R.id.fragment_container,
SelectPathFragment.newInstance(theme, explorerRouter.pickMode),
)
.commit()
}
onBackPressedDispatcher.addCallback(this) {
setResult(RESULT_USER_BACK)
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
if (supportFragmentManager.backStackEntryCount > 0)
supportFragmentManager.popBackStack()
else {
setResult(RESULT_USER_BACK)
finish()
}
}
return super.onOptionsItemSelected(item)
}
fun onFragmentLoaded(selectPathFragment: Boolean) {
title = getString(
if (selectPathFragment) {
R.string.add_volume
} else {
R.string.create_volume
}
)
}
fun startExplorer(volumeId: Int, volumeShortName: String) {
startActivity(explorerRouter.getExplorerIntent(volumeId, volumeShortName))
finish()
}
fun onVolumeAdded() {
setResult(RESULT_USER_BACK)
finish()
}
fun openVolume(volume: VolumeData, isVolumeKnown: Boolean) {
volumeOpener.openVolume(volume, isVolumeKnown, object : VolumeOpener.VolumeOpenerCallbacks {
override fun onVolumeOpened(id: Int) {
startExplorer(id, volume.shortName)
}
})
}
fun createVolume(volumePath: String, isHidden: Boolean, rememberVolume: Boolean) {
supportFragmentManager
.beginTransaction()
.replace(
R.id.fragment_container, CreateVolumeFragment.newInstance(
theme,
volumePath,
isHidden,
rememberVolume,
sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false),
sharedPrefs.getBoolean("usf_fingerprint", false),
)
)
.addToBackStack(null)
.commit()
}
}

View File

@ -0,0 +1,307 @@
package sushi.hardcore.droidfs.add_volume
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.RadioButton
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import sushi.hardcore.droidfs.BuildConfig
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.FingerprintProtector
import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.Theme
import sushi.hardcore.droidfs.VolumeData
import sushi.hardcore.droidfs.VolumeDatabase
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.databinding.FileSystemRadioBinding
import sushi.hardcore.droidfs.databinding.FragmentCreateVolumeBinding
import sushi.hardcore.droidfs.filesystems.CryfsVolume
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File
import java.util.Arrays
class CreateVolumeFragment: Fragment() {
internal data class FileSystemInfo(val nameResource: Int, val detailsResource: Int, val ciphersResource: Int)
companion object {
private const val KEY_THEME_VALUE = "theme"
private const val KEY_VOLUME_PATH = "path"
private const val KEY_IS_HIDDEN = "hidden"
private const val KEY_REMEMBER_VOLUME = "remember"
private const val KEY_PIN_PASSWORDS = Constants.PIN_PASSWORDS_KEY
private const val KEY_USF_FINGERPRINT = "fingerprint"
private val GOCRYPTFS_INFO = FileSystemInfo(
R.string.gocryptfs,
R.string.gocryptfs_details,
R.array.gocryptfs_encryption_ciphers,
)
private val CRYFS_INFO = FileSystemInfo(
R.string.cryfs,
R.string.cryfs_details,
R.array.cryfs_encryption_ciphers,
)
fun newInstance(
theme: Theme,
volumePath: String,
isHidden: Boolean,
rememberVolume: Boolean,
pinPasswords: Boolean,
usfFingerprint: Boolean,
): CreateVolumeFragment {
return CreateVolumeFragment().apply {
arguments = Bundle().apply {
putParcelable(KEY_THEME_VALUE, theme)
putString(KEY_VOLUME_PATH, volumePath)
putBoolean(KEY_IS_HIDDEN, isHidden)
putBoolean(KEY_REMEMBER_VOLUME, rememberVolume)
putBoolean(KEY_PIN_PASSWORDS, pinPasswords)
putBoolean(KEY_USF_FINGERPRINT, usfFingerprint)
}
}
}
}
private lateinit var binding: FragmentCreateVolumeBinding
private lateinit var theme: Theme
private val fileSystemInfos = ArrayList<FileSystemInfo>(2)
private lateinit var volumePath: String
private var isHiddenVolume: Boolean = false
private var rememberVolume: Boolean = false
private var usfFingerprint: Boolean = false
private lateinit var volumeDatabase: VolumeDatabase
private var fingerprintProtector: FingerprintProtector? = null
private var hashStorageReset = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentCreateVolumeBinding.inflate(layoutInflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val pinPasswords = requireArguments().let { arguments ->
theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
volumePath = arguments.getString(KEY_VOLUME_PATH)!!
isHiddenVolume = arguments.getBoolean(KEY_IS_HIDDEN)
rememberVolume = arguments.getBoolean(KEY_REMEMBER_VOLUME)
usfFingerprint = arguments.getBoolean(KEY_USF_FINGERPRINT)
arguments.getBoolean(KEY_PIN_PASSWORDS)
}
volumeDatabase = VolumeDatabase(requireContext())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
fingerprintProtector = FingerprintProtector.new(requireActivity(), theme, volumeDatabase)
}
if (!rememberVolume || !usfFingerprint || fingerprintProtector == null) {
binding.checkboxSavePassword.visibility = View.GONE
}
if (!BuildConfig.GOCRYPTFS_DISABLED) {
fileSystemInfos.add(GOCRYPTFS_INFO)
}
if (!BuildConfig.CRYFS_DISABLED) {
fileSystemInfos.add(CRYFS_INFO)
}
val encryptionCipherAdapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_item,
resources.getStringArray(R.array.gocryptfs_encryption_ciphers).toMutableList()
).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
for ((i, fs) in fileSystemInfos.iterator().withIndex()) {
with(FileSystemRadioBinding.inflate(layoutInflater)) {
title.text = getString(fs.nameResource)
details.text = getString(fs.detailsResource)
radio.isChecked = i == 0
root.setOnClickListener {
radio.performClick()
}
radio.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
with(encryptionCipherAdapter) {
clear()
addAll(resources.getStringArray(fs.ciphersResource).asList())
}
binding.radioGroupFilesystems.children.forEach {
if (it != root) {
it.findViewById<RadioButton>(R.id.radio).isChecked = false
}
}
}
}
binding.radioGroupFilesystems.addView(root)
}
}
binding.spinnerCipher.adapter = encryptionCipherAdapter
if (pinPasswords) {
arrayOf(binding.editPassword, binding.editPasswordConfirm).forEach {
it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
}
}
binding.editPasswordConfirm.setOnEditorActionListener { _, _, _ ->
createVolume()
true
}
binding.buttonCreate.setOnClickListener {
createVolume()
}
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
(activity as AddVolumeActivity).onFragmentLoaded(false)
}
private fun getSelectedFileSystemIndex(): Int {
for ((i, child) in binding.radioGroupFilesystems.children.iterator().withIndex()) {
if (child.findViewById<RadioButton>(R.id.radio).isChecked) {
return i
}
}
return -1
}
private fun createVolume() {
val password = UIUtils.encodeEditTextContent(binding.editPassword)
val passwordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
if (!password.contentEquals(passwordConfirm)) {
Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
Arrays.fill(password, 0)
Arrays.fill(passwordConfirm, 0)
} else {
Arrays.fill(passwordConfirm, 0)
val returnedHash: ObjRef<ByteArray?>? = if (binding.checkboxSavePassword.isChecked) {
ObjRef(null)
} else {
null
}
val encryptedVolume = ObjRef<EncryptedVolume?>(null)
object: LoadingTask<Byte>(requireActivity() as AppCompatActivity, theme, R.string.loading_msg_create) {
private fun generateResult(success: Boolean, volumeType: Byte): Byte {
return if (success) {
volumeType
} else {
-1
}
}
override suspend fun doTask(): Byte {
val volumeFile = File(volumePath)
if (!volumeFile.exists())
volumeFile.mkdirs()
val result = if (fileSystemInfos[getSelectedFileSystemIndex()] == GOCRYPTFS_INFO) {
val xchacha = when (binding.spinnerCipher.selectedItemPosition) {
0 -> -1 // auto
1 -> 0 // AES-GCM
else -> 1 // XChaCha20-Poly1305
}
generateResult(GocryptfsVolume.createAndOpenVolume(
volumePath,
password,
false,
xchacha,
returnedHash?.apply {
value = ByteArray(GocryptfsVolume.KeyLen)
}?.value,
encryptedVolume,
), EncryptedVolume.GOCRYPTFS_VOLUME_TYPE)
} else {
encryptedVolume.value = CryfsVolume.create(
volumePath,
CryfsVolume.getLocalStateDir(activity.filesDir.path),
password,
returnedHash,
resources.getStringArray(R.array.cryfs_encryption_ciphers)[binding.spinnerCipher.selectedItemPosition],
)
generateResult(encryptedVolume.value != null, EncryptedVolume.CRYFS_VOLUME_TYPE)
}
Arrays.fill(password, 0)
return result
}
}.startTask(lifecycleScope) { result ->
if (result.compareTo(-1) == 0) {
CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error)
.setMessage(R.string.create_volume_failed)
.setPositiveButton(R.string.ok, null)
.show()
} else {
val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath
val volume = VolumeData(VolumeData.newUuid(), volumeName, isHiddenVolume, result)
var isVolumeSaved = false
volumeDatabase.apply {
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
removeVolume(volume)
if (rememberVolume) {
isVolumeSaved = saveVolume(volume)
}
}
val volumeId = encryptedVolume.value?.let {
(activity?.application as VolumeManagerApp).volumeManager.insert(it, volume)
}
@SuppressLint("NewApi") // if fingerprintProtector is null checkboxSavePassword is hidden
if (isVolumeSaved && binding.checkboxSavePassword.isChecked && returnedHash != null) {
fingerprintProtector!!.let {
it.listener = object : FingerprintProtector.Listener {
override fun onHashStorageReset() {
hashStorageReset = true
// retry
it.savePasswordHash(volume, returnedHash.value!!)
}
override fun onPasswordHashDecrypted(hash: ByteArray) {} // shouldn't happen here
override fun onPasswordHashSaved() {
Arrays.fill(returnedHash.value!!, 0)
onVolumeCreated(volumeId, volume.shortName)
}
override fun onFailed(pending: Boolean) {
if (!pending) {
Arrays.fill(returnedHash.value!!, 0)
onVolumeCreated(volumeId, volume.shortName)
}
}
}
it.savePasswordHash(volume, returnedHash.value!!)
}
} else {
onVolumeCreated(volumeId, volume.shortName)
}
}
}
}
}
private fun onVolumeCreated(id: Int?, volumeShortName: String) {
(activity as AddVolumeActivity).apply {
if (id == null) {
finish()
} else {
startExplorer(id, volumeShortName)
}
}
}
override fun onStop() {
super.onStop()
binding.editPassword.text.clear()
binding.editPasswordConfirm.text.clear()
}
}

View File

@ -0,0 +1,408 @@
package sushi.hardcore.droidfs.add_volume
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel
import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.Theme
import sushi.hardcore.droidfs.VolumeData
import sushi.hardcore.droidfs.VolumeDatabase
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.databinding.DialogSdcardErrorBinding
import sushi.hardcore.droidfs.databinding.FragmentSelectPathBinding
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File
class SelectPathFragment: Fragment() {
internal class InputViewModel: ViewModel() {
var showEditText = false
}
companion object {
private const val KEY_THEME_VALUE = "theme"
private const val KEY_PICK_MODE = "pick"
fun newInstance(theme: Theme, pickMode: Boolean): SelectPathFragment {
return SelectPathFragment().apply {
arguments = Bundle().apply {
putParcelable(KEY_THEME_VALUE, theme)
putBoolean(KEY_PICK_MODE, pickMode)
}
}
}
}
private lateinit var binding: FragmentSelectPathBinding
private val askStoragePermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
if (result[Manifest.permission.READ_EXTERNAL_STORAGE] == true && result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true)
launchPickDirectory()
else
CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.storage_perm_denied)
.setMessage(R.string.storage_perm_denied_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok, null)
.show()
}
private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null)
onDirectoryPicked(uri)
}
private lateinit var app: VolumeManagerApp
private lateinit var theme: Theme
private lateinit var volumeDatabase: VolumeDatabase
private lateinit var filesDir: String
private lateinit var sharedPrefs: SharedPreferences
private var pickMode = false
private var originalRememberVolume = true
private var currentVolumeData: VolumeData? = null
private var volumeAction: Action? = null
private val inputViewModel: InputViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentSelectPathBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
app = requireActivity().application as VolumeManagerApp
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
originalRememberVolume = sharedPrefs.getBoolean(Constants.REMEMBER_VOLUME_KEY, true)
binding.switchRemember.isChecked = originalRememberVolume
arguments?.let { arguments ->
theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
pickMode = arguments.getBoolean(KEY_PICK_MODE)
}
volumeDatabase = VolumeDatabase(requireContext())
filesDir = requireContext().filesDir.path
binding.containerHiddenVolume.setOnClickListener {
binding.switchHiddenVolume.performClick()
}
binding.switchHiddenVolume.setOnClickListener {
updateUi()
}
binding.buttonPickDirectory.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Environment.isExternalStorageManager()) {
launchPickDirectory()
} else {
app.isStartingExternalApp = true
startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:"+requireContext().packageName)))
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (
ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.READ_EXTERNAL_STORAGE
) + ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
) {
launchPickDirectory()
} else {
app.isStartingExternalApp = true
askStoragePermissions.launch(
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
)
}
} else {
launchPickDirectory()
}
}
binding.buttonEnterPath.setOnClickListener {
inputViewModel.showEditText = true
updateUi()
binding.editVolumeName.requestFocus()
(app.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(
binding.editVolumeName,
InputMethodManager.SHOW_IMPLICIT
)
}
binding.editVolumeName.addTextChangedListener(object: TextWatcher {
override fun afterTextChanged(s: Editable?) {}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
updateUi(s)
}
})
binding.switchRemember.setOnCheckedChangeListener { _, _ -> updateUi() }
binding.editVolumeName.setOnEditorActionListener { _, _, _ ->
if (binding.editVolumeName.text.isEmpty()) {
Toast.makeText(
requireContext(),
if (binding.switchHiddenVolume.isChecked) R.string.empty_volume_name else R.string.empty_volume_path,
Toast.LENGTH_SHORT
).show()
} else {
onPathSelected()
}
true
}
binding.buttonAction.setOnClickListener { onPathSelected() }
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
(activity as AddVolumeActivity).onFragmentLoaded(true)
}
private fun launchPickDirectory() {
app.isStartingExternalApp = true
PathUtils.safePickDirectory(pickDirectory, requireContext(), theme)
}
private fun updateUi(volumeName: CharSequence = binding.editVolumeName.text) {
var warning = -1
fun updateWarning() {
if (warning == -1) {
binding.textWarning.isVisible = false
} else {
binding.textWarning.isVisible = true
binding.textWarning.text = getString(warning)
}
}
val hidden = binding.switchHiddenVolume.isChecked
binding.editVolumeName.isVisible = hidden || inputViewModel.showEditText
binding.buttonPickDirectory.isVisible = !hidden
binding.textOr.isVisible = !hidden && !inputViewModel.showEditText
binding.buttonEnterPath.isVisible = !hidden && !inputViewModel.showEditText
if (hidden) {
binding.textLabel.text = getString(R.string.volume_name_label)
binding.editVolumeName.hint = getString(R.string.volume_name_hint)
} else {
binding.textLabel.text = getString(R.string.volume_path_label)
binding.editVolumeName.hint = getString(R.string.volume_path_hint)
}
if (hidden && volumeName.contains(PathUtils.SEPARATOR)) {
warning = R.string.error_slash_in_name
}
// exit early if possible to avoid filesystem queries
if (volumeName.isEmpty() || warning != -1 || (!hidden && !inputViewModel.showEditText)) {
binding.buttonAction.isVisible = false
binding.switchRemember.isVisible = false
updateWarning()
return
}
val path = File(getCurrentVolumePath())
volumeAction = if (path.isDirectory) {
if (path.list()?.isEmpty() == true) {
Action.CREATE
} else if (pickMode || !binding.switchRemember.isChecked) {
Action.OPEN
} else {
Action.ADD
}
} else {
Action.CREATE
}
val valid = !(volumeAction == Action.CREATE && pickMode)
binding.switchRemember.isVisible = valid
binding.buttonAction.isVisible = valid
if (valid) {
binding.buttonAction.text = getString(volumeAction!!.getStringResId())
currentVolumeData = if (volumeAction == Action.CREATE) {
null
} else {
volumeDatabase.getVolume(volumeName.toString(), hidden)
}
if (currentVolumeData != null) {
warning = R.string.volume_alread_saved
}
} else {
warning = R.string.choose_existing_volume
}
updateWarning()
}
private fun onDirectoryPicked(uri: Uri) {
val path = PathUtils.getFullPathFromTreeUri(uri, requireContext())
if (path == null) {
CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error)
.setMessage(R.string.path_error)
.setPositiveButton(R.string.ok, null)
.show()
} else {
inputViewModel.showEditText = true
binding.editVolumeName.setText(path)
}
}
private fun getCurrentVolumePath(): String {
return if (binding.switchHiddenVolume.isChecked)
VolumeData.getHiddenVolumeFullPath(filesDir, binding.editVolumeName.text.toString())
else
binding.editVolumeName.text.toString()
}
private fun onPathSelected() {
if (binding.switchRemember.isChecked != originalRememberVolume) {
with(sharedPrefs.edit()) {
putBoolean(Constants.REMEMBER_VOLUME_KEY, binding.switchRemember.isChecked)
apply()
}
}
if (currentVolumeData == null) { // volume not known
val currentVolumeValue = binding.editVolumeName.text.toString()
val isHidden = binding.switchHiddenVolume.isChecked
if (isHidden && currentVolumeValue.contains(PathUtils.SEPARATOR)) {
Toast.makeText(requireContext(), R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
} else if (isHidden && volumeAction == Action.CREATE) {
CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.warning)
.setMessage(R.string.hidden_volume_warning)
.setPositiveButton(R.string.ok) { _, _ ->
onNewVolumeSelected(currentVolumeValue, isHidden)
}
.show()
} else {
onNewVolumeSelected(currentVolumeValue, isHidden)
}
} else {
with (activity as AddVolumeActivity) {
if (volumeAction!! == Action.OPEN) {
openVolume(currentVolumeData!!, true)
} else {
onVolumeAdded()
}
}
}
}
private fun onNewVolumeSelected(currentVolumeValue: String, isHidden: Boolean) {
val volumePath = getCurrentVolumePath()
if (volumeAction!! == Action.CREATE) {
val volumeFile = File(volumePath)
var goodDirectory = false
if (volumeFile.isFile) {
Toast.makeText(requireContext(), R.string.error_is_file, Toast.LENGTH_SHORT).show()
} else if (volumeFile.isDirectory) {
val dirContent = volumeFile.list()
if (dirContent != null) {
if (dirContent.isEmpty()) {
if (volumeFile.canWrite()) {
goodDirectory = true
} else {
errorDirectoryNotWritable(volumePath)
}
} else {
Toast.makeText(requireContext(), R.string.dir_not_empty, Toast.LENGTH_SHORT)
.show()
}
} else {
Toast.makeText(
requireContext(),
R.string.listdir_null_error_msg,
Toast.LENGTH_SHORT
).show()
}
} else {
if (File(PathUtils.getParentPath(volumePath)).canWrite()) {
goodDirectory = true
} else {
errorDirectoryNotWritable(volumePath)
}
}
if (goodDirectory) {
(activity as AddVolumeActivity).createVolume(
volumePath,
isHidden,
binding.switchRemember.isChecked
)
}
} else {
val volumeType = EncryptedVolume.getVolumeType(volumePath)
if (volumeType < 0) {
CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error)
.setMessage(R.string.error_not_a_volume)
.setPositiveButton(R.string.ok, null)
.show()
} else if (!File(volumePath).canWrite()) {
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.warning)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext())) {
dialog.setView(
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
path.text = PathUtils.getPackageDataFolder(requireContext())
footer.text = getString(R.string.sdcard_error_add_footer)
}.root
)
} else {
dialog.setMessage(R.string.add_cant_write_warning)
}
dialog.show()
} else {
onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
}
}
}
// called when the user tries to create a volume in a non-writable directory
private fun errorDirectoryNotWritable(volumePath: String) {
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error)
.setPositiveButton(R.string.ok, null)
@SuppressLint("InflateParams")
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext()))
dialog.setView(
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
path.text = PathUtils.getPackageDataFolder(requireContext())
}.root
)
else
dialog.setMessage(R.string.create_cant_write_error_msg)
dialog.show()
}
private fun onExistingVolumeSelected(volumeName: String, isHidden: Boolean, volumeType: Byte) {
val volumeData = VolumeData(VolumeData.newUuid(), volumeName, isHidden, volumeType)
if (binding.switchRemember.isChecked) {
volumeDatabase.saveVolume(volumeData)
}
with (activity as AddVolumeActivity) {
if (volumeAction!! == Action.OPEN) {
openVolume(volumeData, binding.switchRemember.isChecked)
} else {
onVolumeAdded()
}
}
}
}

View File

@ -0,0 +1,72 @@
/*
* Code borrowed from the awesome Material Files app (https://github.com/zhanghai/MaterialFiles)
*
* Copyright (c) 2019 Hai Zhang <dreaming.in.code.zh@gmail.com>
* All Rights Reserved.
*/
package sushi.hardcore.droidfs.collation
import kotlin.math.min
class ByteString internal constructor(
private val bytes: ByteArray
) : Comparable<ByteString> {
fun borrowBytes(): ByteArray = bytes
private var stringCache: String? = null
override fun toString(): String {
// We are okay with the potential race condition here.
var string = stringCache
if (string == null) {
// String() uses replacement char instead of throwing exception.
string = String(bytes)
stringCache = string
}
return string
}
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (javaClass != other?.javaClass) {
return false
}
other as ByteString
return bytes contentEquals other.bytes
}
override fun hashCode(): Int = bytes.contentHashCode()
override fun compareTo(other: ByteString): Int = bytes.compareTo(other.bytes)
private fun ByteArray.compareTo(other: ByteArray): Int {
val size = size
val otherSize = other.size
for (index in 0 until min(size, otherSize)) {
val byte = this[index]
val otherByte = other[index]
val result = byte - otherByte
if (result != 0) {
return result
}
}
return size - otherSize
}
companion object {
fun fromBytes(bytes: ByteArray, start: Int = 0, end: Int = bytes.size): ByteString =
ByteString(bytes.copyOfRange(start, end))
fun fromString(string: String): ByteString =
ByteString(string.toByteArray()).apply { stringCache = string }
}
}
fun ByteArray.toByteString(start: Int = 0, end: Int = size): ByteString =
ByteString.fromBytes(this, start, end)
fun String.toByteString(): ByteString = ByteString.fromString(this)

View File

@ -0,0 +1,47 @@
/*
* Code borrowed from the awesome Material Files app (https://github.com/zhanghai/MaterialFiles)
*
* Copyright (c) 2019 Hai Zhang <dreaming.in.code.zh@gmail.com>
* All Rights Reserved.
*/
package sushi.hardcore.droidfs.collation
class ByteStringBuilder(capacity: Int = 16) {
private var bytes = ByteArray(capacity)
var length = 0
private set
fun append(byte: Byte): ByteStringBuilder {
ensureCapacity(length + 1)
bytes[length] = byte
++length
return this
}
fun append(bytes: ByteArray, start: Int = 0, end: Int = bytes.size): ByteStringBuilder {
val newLength = length + (end - start)
ensureCapacity(newLength)
bytes.copyInto(this.bytes, length, start, end)
length = newLength
return this
}
fun append(byteString: ByteString): ByteStringBuilder = append(byteString.borrowBytes())
private fun ensureCapacity(minimumCapacity: Int) {
val capacity = bytes.size
if (minimumCapacity > capacity) {
var newCapacity = (capacity shl 1) + 2
if (newCapacity < minimumCapacity) {
newCapacity = minimumCapacity
}
bytes = bytes.copyOf(newCapacity)
}
}
fun toByteString(): ByteString = bytes.toByteString(0, length)
override fun toString(): String = String(bytes, 0, length)
}

View File

@ -0,0 +1,115 @@
/*
* Code borrowed from the awesome Material Files app (https://github.com/zhanghai/MaterialFiles)
*
* Copyright (c) 2020 Hai Zhang <dreaming.in.code.zh@gmail.com>
* All Rights Reserved.
*/
package sushi.hardcore.droidfs.collation
import java.text.CollationKey
import java.text.Collator
import kotlin.math.min
private val COLLATION_SENTINEL = byteArrayOf(1, 1, 1)
// @see https://github.com/GNOME/glib/blob/mainline/glib/gunicollate.c
// g_utf8_collate_key_for_filename()
fun Collator.getCollationKeyForFileName(source: String): CollationKey {
val result = ByteStringBuilder()
val suffix = ByteStringBuilder()
var previousIndex = 0
var index = 0
val endIndex = source.length
while (index < endIndex) {
when {
source[index] == '.' -> {
if (previousIndex != index) {
val collationKey = getCollationKey(source.substring(previousIndex, index))
result.append(collationKey.toByteArray())
}
result.append(COLLATION_SENTINEL).append(1)
previousIndex = index + 1
}
source[index].isAsciiDigit() -> {
if (previousIndex != index) {
val collationKey = getCollationKey(source.substring(previousIndex, index))
result.append(collationKey.toByteArray())
}
result.append(COLLATION_SENTINEL).append(2)
previousIndex = index
var leadingZeros: Int
var digits: Int
if (source[index] == '0') {
leadingZeros = 1
digits = 0
} else {
leadingZeros = 0
digits = 1
}
while (++index < endIndex) {
if (source[index] == '0' && digits == 0) {
++leadingZeros
} else if (source[index].isAsciiDigit()) {
++digits
} else {
if (digits == 0) {
++digits
--leadingZeros
}
break
}
}
while (digits > 1) {
result.append(':'.code.toByte())
--digits
}
if (leadingZeros > 0) {
suffix.append(leadingZeros.toByte())
previousIndex += leadingZeros
}
result.append(source.substring(previousIndex, index).toByteString())
previousIndex = index
--index
}
else -> {}
}
++index
}
if (previousIndex != index) {
val collationKey = getCollationKey(source.substring(previousIndex, index))
result.append(collationKey.toByteArray())
}
result.append(suffix.toByteString())
return ByteArrayCollationKey(source, result.toByteString().borrowBytes())
}
private fun Char.isAsciiDigit(): Boolean = this in '0'..'9'
private class ByteArrayCollationKey(
@Suppress("CanBeParameter")
private val source: String,
private val bytes: ByteArray
) : CollationKey(source) {
override fun compareTo(other: CollationKey): Int {
other as ByteArrayCollationKey
return bytes.unsignedCompareTo(other.bytes)
}
override fun toByteArray(): ByteArray = bytes.copyOf()
}
private fun ByteArray.unsignedCompareTo(other: ByteArray): Int {
val size = size
val otherSize = other.size
for (index in 0 until min(size, otherSize)) {
val byte = this[index].toInt() and 0xFF
val otherByte = other[index].toInt() and 0xFF
if (byte < otherByte) {
return -1
} else if (byte > otherByte) {
return 1
}
}
return size - otherSize
}

View File

@ -1,115 +0,0 @@
package sushi.hardcore.droidfs.content_providers
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.appcompat.app.AppCompatActivity
import sushi.hardcore.droidfs.GocryptfsVolume
import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
import java.io.File
object ExternalProvider {
private const val content_type_all = "*/*"
private var storedFiles: MutableList<Uri> = ArrayList()
private fun getContentType(filename: String, previous_content_type: String?): String? {
if (content_type_all != previous_content_type) {
var contentType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(filename).extension)
if (contentType == null) {
contentType = content_type_all
}
if (previous_content_type == null) {
return contentType
} else if (previous_content_type != contentType) {
return content_type_all
}
}
return previous_content_type
}
private fun exportFile(context: Context, gocryptfsVolume: GocryptfsVolume, file_path: String, previous_content_type: String?): Pair<Uri?, String?> {
val fileName = File(file_path).name
val tmpFileUri = RestrictedFileProvider.newFile(fileName)
if (tmpFileUri != null){
storedFiles.add(tmpFileUri)
if (gocryptfsVolume.exportFile(context, file_path, tmpFileUri)) {
return Pair(tmpFileUri, getContentType(fileName, previous_content_type))
}
}
return Pair(null, null)
}
fun share(activity: AppCompatActivity, gocryptfsVolume: GocryptfsVolume, file_paths: List<String>) {
object : LoadingTask(activity, R.string.loading_msg_export){
override fun doTask(activity: AppCompatActivity) {
var contentType: String? = null
val uris = ArrayList<Uri>()
for (path in file_paths) {
val result = exportFile(activity, gocryptfsVolume, path, contentType)
contentType = if (result.first != null) {
uris.add(result.first!!)
result.second
} else {
stopTask {
ColoredAlertDialogBuilder(activity)
.setTitle(R.string.error)
.setMessage(activity.getString(R.string.export_failed, path))
.setPositiveButton(R.string.ok, null)
.show()
}
return
}
}
val shareIntent = Intent()
shareIntent.type = contentType
if (uris.size == 1) {
shareIntent.action = Intent.ACTION_SEND
shareIntent.putExtra(Intent.EXTRA_STREAM, uris[0])
} else {
shareIntent.action = Intent.ACTION_SEND_MULTIPLE
shareIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
}
stopTask {
activity.startActivity(Intent.createChooser(shareIntent, activity.getString(R.string.share_chooser)))
}
}
}
}
fun open(activity: AppCompatActivity, gocryptfsVolume: GocryptfsVolume, file_path: String) {
object : LoadingTask(activity, R.string.loading_msg_export) {
override fun doTask(activity: AppCompatActivity) {
val result = exportFile(activity, gocryptfsVolume, file_path, null)
if (result.first != null) {
val openIntent = Intent(Intent.ACTION_VIEW)
openIntent.setDataAndType(result.first, result.second)
stopTask { activity.startActivity(openIntent) }
} else {
stopTask {
ColoredAlertDialogBuilder(activity)
.setTitle(R.string.error)
.setMessage(activity.getString(R.string.export_failed, file_path))
.setPositiveButton(R.string.ok, null)
.show()
}
}
}
}
}
fun removeFiles(context: Context) {
Thread{
val success = ArrayList<Uri>()
for (uri in storedFiles){
if (context.contentResolver.delete(uri, null, null) == 1){
success.add(uri)
}
}
for (uri in success){
storedFiles.remove(uri)
}
}.start()
}
}

View File

@ -1,194 +0,0 @@
package sushi.hardcore.droidfs.content_providers
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import sushi.hardcore.droidfs.BuildConfig
import sushi.hardcore.droidfs.util.SQLUtil.appendSelectionArgs
import sushi.hardcore.droidfs.util.SQLUtil.concatenateWhere
import sushi.hardcore.droidfs.util.Wiper
import java.io.File
import java.util.*
import java.util.regex.Pattern
class RestrictedFileProvider: ContentProvider() {
companion object {
private const val DB_NAME = "temporary_files.db"
private const val TABLE_FILES = "files"
private const val DB_VERSION = 3
private var dbHelper: RestrictedDatabaseHelper? = null
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".temporary_provider"
private val CONTENT_URI: Uri = Uri.parse("content://$AUTHORITY")
const val TEMPORARY_FILES_DIR_NAME = "temp"
private val UUID_PATTERN = Pattern.compile("[a-fA-F0-9-]+")
private lateinit var tempFilesDir: File
internal class TemporaryFileColumns {
companion object {
const val COLUMN_UUID = "uuid"
const val COLUMN_NAME = "name"
}
}
internal class RestrictedDatabaseHelper(context: Context?): SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS " + TABLE_FILES + " (" +
TemporaryFileColumns.COLUMN_UUID + " TEXT PRIMARY KEY, " +
TemporaryFileColumns.COLUMN_NAME + " TEXT" +
");"
)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
if (oldVersion == 1) {
db.execSQL("DROP TABLE IF EXISTS files")
db.execSQL(
"CREATE TABLE IF NOT EXISTS " + TABLE_FILES + " (" +
TemporaryFileColumns.COLUMN_UUID + " TEXT PRIMARY KEY, " +
TemporaryFileColumns.COLUMN_NAME + " TEXT" +
");"
)
}
}
}
fun newFile(fileName: String): Uri? {
val uuid = UUID.randomUUID().toString()
val file = File(tempFilesDir, uuid)
return if (file.createNewFile()){
val contentValues = ContentValues()
contentValues.put(TemporaryFileColumns.COLUMN_UUID, uuid)
contentValues.put(TemporaryFileColumns.COLUMN_NAME, fileName)
if (dbHelper?.writableDatabase?.insert(TABLE_FILES, null, contentValues)?.toInt() != -1){
Uri.withAppendedPath(CONTENT_URI, uuid)
} else {
null
}
} else {
null
}
}
fun wipeAll(context: Context) {
tempFilesDir.listFiles()?.let{
for (file in it) {
Wiper.wipe(file)
}
}
dbHelper?.close()
context.deleteDatabase(DB_NAME)
}
private fun isValidUUID(uuid: String): Boolean {
return UUID_PATTERN.matcher(uuid).matches()
}
private fun getUuidFromUri(uri: Uri): String? {
val uuid = uri.lastPathSegment
if (uuid != null) {
if (isValidUUID(uuid)) {
return uuid
}
}
return null
}
private fun getFileFromUUID(uuid: String): File? {
if (isValidUUID(uuid)){
return File(tempFilesDir, uuid)
}
return null
}
private fun getFileFromUri(uri: Uri): File? {
getUuidFromUri(uri)?.let {
return getFileFromUUID(it)
}
return null
}
}
override fun onCreate(): Boolean {
context?.let {
dbHelper = RestrictedDatabaseHelper(it)
tempFilesDir = File(it.cacheDir, TEMPORARY_FILES_DIR_NAME)
return tempFilesDir.mkdirs()
}
return false
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
throw RuntimeException("Operation not supported")
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {
throw RuntimeException("Operation not supported")
}
override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
var resultCursor: MatrixCursor? = null
val temporaryFile = getFileFromUri(uri)
temporaryFile?.let{
val fileName = dbHelper?.readableDatabase?.query(TABLE_FILES, arrayOf(TemporaryFileColumns.COLUMN_NAME), TemporaryFileColumns.COLUMN_UUID + "=?", arrayOf(uri.lastPathSegment), null, null, null)
fileName?.let{
if (fileName.moveToNext()) {
resultCursor = MatrixCursor(
arrayOf(
MediaStore.MediaColumns.DISPLAY_NAME,
MediaStore.MediaColumns.SIZE
)
)
resultCursor!!.newRow()
.add(fileName.getString(0))
.add(temporaryFile.length())
}
fileName.close()
}
}
return resultCursor
}
override fun delete(uri: Uri, givenSelection: String?, givenSelectionArgs: Array<String>?): Int {
val uuid = getUuidFromUri(uri)
uuid?.let{
val selection = concatenateWhere(givenSelection ?: "" , TemporaryFileColumns.COLUMN_UUID + "=?")
val selectionArgs = appendSelectionArgs(givenSelectionArgs, arrayOf(it))
val files = dbHelper?.readableDatabase?.query(TABLE_FILES, arrayOf(TemporaryFileColumns.COLUMN_UUID), selection, selectionArgs, null, null, null)
if (files != null) {
while (files.moveToNext()) {
getFileFromUUID(files.getString(0))?.let { file ->
Wiper.wipe(file)
}
}
files.close()
return dbHelper?.writableDatabase?.delete(TABLE_FILES, selection, selectionArgs) ?: 0
}
}
return 0
}
override fun getType(uri: Uri): String {
return "application/octet-stream"
}
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
if (("w" in mode && callingPackage == BuildConfig.APPLICATION_ID) || "w" !in mode) {
getFileFromUri(uri)?.let{
return ParcelFileDescriptor.open(it, ParcelFileDescriptor.parseMode(mode))
}
} else {
throw SecurityException("Read-only access")
}
return null
}
}

View File

@ -0,0 +1,147 @@
package sushi.hardcore.droidfs.content_providers
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Intent
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.BuildConfig
import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.VolumeManager
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.util.AndroidUtils
import sushi.hardcore.droidfs.util.Wiper
import java.io.File
import java.util.UUID
class TemporaryFileProvider : ContentProvider() {
private inner class ProvidedFile(
val file: EncryptedFileProvider.ExportedFile,
val size: Long,
val volumeId: Int
)
companion object {
private const val TAG = "TemporaryFileProvider"
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".temporary_provider"
private val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")
lateinit var instance: TemporaryFileProvider
private set
}
private val usfSafWriteDelegate = AndroidUtils.LiveBooleanPreference("usf_saf_write", false)
private val usfSafWrite by usfSafWriteDelegate
private lateinit var volumeManager: VolumeManager
lateinit var encryptedFileProvider: EncryptedFileProvider
private val files = HashMap<Uri, ProvidedFile>()
override fun onCreate(): Boolean {
return context?.let {
volumeManager = (it.applicationContext as VolumeManagerApp).volumeManager
usfSafWriteDelegate.init(it)
encryptedFileProvider = EncryptedFileProvider(it)
instance = this
val tmpFilesDir = EncryptedFileProvider.getTmpFilesDir(it)
val success = tmpFilesDir.mkdirs()
// wipe any additional files not previously deleted
GlobalScope.launch(Dispatchers.IO) {
tmpFilesDir.listFiles()?.onEach { f -> Wiper.wipe(f) }
}
success
} ?: false
}
fun exportFile(
exportedFile: EncryptedFileProvider.ExportedFile,
size: Long,
volumeId: Int
): Uri? {
if (!encryptedFileProvider.exportFile(exportedFile, volumeManager.getVolume(volumeId)!!)) {
return null
}
return Uri.withAppendedPath(BASE_URI, UUID.randomUUID().toString()).also {
files[it] = ProvidedFile(exportedFile, size, volumeId)
}
}
override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
): Cursor? {
val file = files[uri] ?: return null
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), 1).apply {
addRow(arrayOf(File(file.file.path).name, file.size))
}
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
throw UnsupportedOperationException("Operation not supported")
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<String>?
): Int {
throw UnsupportedOperationException("Operation not supported")
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
return if (files.remove(uri)?.file?.also { it.free() } == null) 0 else 1
}
override fun getType(uri: Uri): String = files[uri]?.file?.path?.let {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(it).extension)
} ?: "application/octet-stream"
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
files[uri]?.let { file ->
val encryptedVolume = volumeManager.getVolume(file.volumeId) ?: run {
Log.e(TAG, "Volume closed for $uri")
return null
}
val result = encryptedFileProvider.openFile(
file.file,
mode,
encryptedVolume,
volumeManager.getCoroutineScope(file.volumeId),
false,
usfSafWrite,
)
when (result.second) {
EncryptedFileProvider.Error.SUCCESS -> return result.first!!
EncryptedFileProvider.Error.WRITE_ACCESS_DENIED -> Log.e(
TAG,
"Unauthorized write access requested from $callingPackage to $uri"
)
else -> result.second.log()
}
}
return null
}
// this must not be cancelled
fun wipe() = GlobalScope.launch(Dispatchers.IO) {
context!!.revokeUriPermission(BASE_URI, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
synchronized(this@TemporaryFileProvider) {
for (i in files.values) {
i.file.free()
}
files.clear()
}
}
}

View File

@ -0,0 +1,294 @@
package sushi.hardcore.droidfs.content_providers
import android.content.Context
import android.database.Cursor
import android.database.MatrixCursor
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.BuildConfig
import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeData
import sushi.hardcore.droidfs.VolumeManager
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.AndroidUtils
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
class VolumeProvider: DocumentsProvider() {
companion object {
private const val TAG = "DocumentsProvider"
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".volume_provider"
private val DEFAULT_ROOT_PROJECTION = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
)
private val DEFAULT_DOCUMENT_PROJECTION = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
)
fun notifyRootsChanged(context: Context) {
context.contentResolver.notifyChange(DocumentsContract.buildRootsUri(AUTHORITY), null)
}
}
private val usfExposeDelegate = AndroidUtils.LiveBooleanPreference("usf_expose", false)
private val usfExpose by usfExposeDelegate
private val usfSafWriteDelegate = AndroidUtils.LiveBooleanPreference("usf_saf_write", false)
private val usfSafWrite by usfSafWriteDelegate
private lateinit var volumeManager: VolumeManager
private val volumes = HashMap<String, Pair<Int, VolumeData>>()
private lateinit var encryptedFileProvider: EncryptedFileProvider
override fun onCreate(): Boolean {
val context = (context ?: return false)
AndroidUtils.LiveBooleanPreference.init(context, usfExposeDelegate, usfSafWriteDelegate)
volumeManager = (context.applicationContext as VolumeManagerApp).volumeManager
encryptedFileProvider = EncryptedFileProvider(context)
return true
}
override fun queryRoots(projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
if (!usfExpose) return cursor
volumes.clear()
for (volume in volumeManager.listVolumes()) {
var flags = DocumentsContract.Root.FLAG_LOCAL_ONLY or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
if (usfSafWrite && volume.second.canWrite(context!!.filesDir.path)) {
flags = flags or DocumentsContract.Root.FLAG_SUPPORTS_CREATE
}
cursor.newRow().apply {
add(DocumentsContract.Root.COLUMN_ROOT_ID, volume.second.name)
add(DocumentsContract.Root.COLUMN_FLAGS, flags)
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.icon_document_provider)
add(DocumentsContract.Root.COLUMN_TITLE, volume.second.name)
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, volume.second.uuid)
}
volumes[volume.second.uuid] = volume
}
return cursor
}
internal data class DocumentData(
val rootId: String,
val volumeId: Int,
val volumeData: VolumeData,
val encryptedVolume: EncryptedVolume,
val path: String
) {
fun child(childPath: String) = DocumentData(rootId, volumeId, volumeData, encryptedVolume, childPath)
}
private fun parseDocumentId(documentId: String): DocumentData? {
val splits = documentId.split("/", limit = 2)
if (splits.size > 2) {
return null
} else {
volumes[splits[0]]?.let {
val encryptedVolume = volumeManager.getVolume(it.first) ?: return null
val path = "/"+if (splits.size == 2) {
splits[1]
} else {
""
}
return DocumentData(splits[0], it.first, it.second, encryptedVolume, path)
}
}
return null
}
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
if (!usfExpose) return false
val parent = parseDocumentId(parentDocumentId) ?: return false
val child = parseDocumentId(documentId) ?: return false
return parent.rootId == child.rootId && PathUtils.isChildOf(child.path, parent.path)
}
private fun addDocumentRow(cursor: MatrixCursor, volumeData: VolumeData, documentId: String, name: String, stat: Stat) {
val isDirectory = stat.type == Stat.S_IFDIR
var flags = 0
if (usfSafWrite && volumeData.canWrite(context!!.filesDir.path)) {
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE or DocumentsContract.Document.FLAG_SUPPORTS_RENAME
if (isDirectory) {
flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
} else if (stat.type == Stat.S_IFREG) {
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE
}
}
val mimeType = if (isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
} else {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(name).extension)
?: "application/octet-stream"
}
cursor.newRow().apply {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId)
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, name)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType)
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
add(DocumentsContract.Document.COLUMN_SIZE, stat.size)
add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, stat.mTime)
}
}
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
if (!usfExpose) return cursor
val document = parseDocumentId(documentId) ?: return cursor
document.encryptedVolume.getAttr(document.path)?.let { stat ->
val name = if (document.path == "/") {
document.volumeData.shortName
} else {
File(document.path).name
}
addDocumentRow(cursor, document.volumeData, documentId, name, stat)
}
return cursor
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<out String>?,
sortOrder: String?
): Cursor {
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
if (!usfExpose) return cursor
val document = parseDocumentId(parentDocumentId) ?: return cursor
document.encryptedVolume.readDir(document.path)?.let { content ->
for (i in content) {
if (i.isParentFolder) continue
addDocumentRow(cursor, document.volumeData, document.rootId+i.fullPath, i.name, i.stat)
}
}
return cursor
}
class LazyExportedFile(
private val encryptedFileProvider: EncryptedFileProvider,
private val encryptedVolume: EncryptedVolume,
path: String,
) : EncryptedFileProvider.ExportedFile(path) {
private val exportedFile: EncryptedFileProvider.ExportedFile by lazy {
val size = encryptedVolume.getAttr(path)?.size ?: run {
Log.e(TAG, "stat() failed")
throw RuntimeException("stat() failed")
}
val exportedFile = encryptedFileProvider.createFile(path, size) ?: run {
Log.e(TAG, "Can't create exported file")
throw RuntimeException("Can't create exported file")
}
if (!encryptedFileProvider.exportFile(exportedFile, encryptedVolume)) {
Log.e(TAG, "File export failed")
throw RuntimeException("File export failed")
}
exportedFile
}
override fun open(mode: Int, furtive: Boolean) = exportedFile.open(mode, furtive)
override fun free() = exportedFile.free()
}
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?
): ParcelFileDescriptor? {
if (!usfExpose) return null
val document = parseDocumentId(documentId) ?: return null
val lazyExportedFile = LazyExportedFile(encryptedFileProvider, document.encryptedVolume, document.path)
val result = encryptedFileProvider.openFile(
lazyExportedFile,
mode,
document.encryptedVolume,
volumeManager.getCoroutineScope(document.volumeId),
true,
usfSafWrite,
)
when (result.second) {
EncryptedFileProvider.Error.SUCCESS -> return result.first!!
EncryptedFileProvider.Error.WRITE_ACCESS_DENIED -> Log.e(TAG, "Unauthorized write access requested from $callingPackage")
else -> result.second.log()
}
return null
}
override fun createDocument(
parentDocumentId: String,
mimeType: String?,
displayName: String
): String? {
if (!usfExpose || !usfSafWrite) return null
val document = parseDocumentId(parentDocumentId) ?: return null
val path = PathUtils.pathJoin(document.path, displayName)
var success = false
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
success = document.encryptedVolume.mkdir(path)
} else {
val f = document.encryptedVolume.openFileWriteMode(path)
if (f != -1L) {
document.encryptedVolume.closeFile(f)
success = true
}
}
return if (success) {
document.rootId+path
} else {
null
}
}
override fun deleteDocument(documentId: String) {
if (!usfExpose || !usfSafWrite) return
fun recursiveRemoveDirectory(document: DocumentData) {
document.encryptedVolume.readDir(document.path)?.forEach { e ->
val childPath = PathUtils.pathJoin(document.path, e.name)
if (e.isDirectory) {
recursiveRemoveDirectory(document.child(childPath))
} else {
document.encryptedVolume.deleteFile(childPath)
}
revokeDocumentPermission(document.rootId+childPath)
}
document.encryptedVolume.rmdir(document.path)
}
val document = parseDocumentId(documentId) ?: return
document.encryptedVolume.getAttr(document.path)?.let { stat ->
if (stat.type == Stat.S_IFDIR) {
recursiveRemoveDirectory(document)
} else {
document.encryptedVolume.deleteFile(document.path)
}
}
}
override fun renameDocument(documentId: String, displayName: String): String {
if (!usfExpose || !usfSafWrite) return documentId
val document = parseDocumentId(documentId) ?: return documentId
val newPath = PathUtils.pathJoin(PathUtils.getParentPath(document.path), displayName)
return if (document.encryptedVolume.rename(document.path, newPath)) {
document.rootId+newPath
} else {
documentId
}
}
}

View File

@ -1,55 +1,66 @@
package sushi.hardcore.droidfs.explorers package sushi.hardcore.droidfs.explorers
import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.IBinder
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.WindowManager import android.widget.ImageButton
import android.widget.AdapterView.OnItemClickListener import android.widget.ProgressBar
import android.widget.AdapterView.OnItemLongClickListener
import android.widget.EditText
import android.widget.ListView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.documentfile.provider.DocumentFile import androidx.activity.addCallback
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.BaseActivity import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.ConstValues.Companion.isAudio import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.ConstValues.Companion.isImage import sushi.hardcore.droidfs.FileShare
import sushi.hardcore.droidfs.ConstValues.Companion.isText import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.ConstValues.Companion.isVideo import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.GocryptfsVolume
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.adapters.DialogSingleChoiceAdapter import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter
import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
import sushi.hardcore.droidfs.content_providers.ExternalProvider import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
import sushi.hardcore.droidfs.file_operations.FileOperationService import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.OperationFile import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.file_viewers.AudioPlayer import sushi.hardcore.droidfs.file_viewers.AudioPlayer
import sushi.hardcore.droidfs.file_viewers.ImageViewer import sushi.hardcore.droidfs.file_viewers.ImageViewer
import sushi.hardcore.droidfs.file_viewers.PdfViewer
import sushi.hardcore.droidfs.file_viewers.TextEditor import sushi.hardcore.droidfs.file_viewers.TextEditor
import sushi.hardcore.droidfs.file_viewers.VideoPlayer import sushi.hardcore.droidfs.file_viewers.VideoPlayer
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.util.finishOnClose
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
open class BaseExplorerActivity : BaseActivity() { open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listener {
private lateinit var sortOrderEntries: Array<String> private lateinit var sortOrderEntries: Array<String>
private lateinit var sortOrderValues: Array<String> private lateinit var sortOrderValues: Array<String>
private var foldersFirst = true private var foldersFirst = true
private var mapFolders = true private var mapFolders = true
private var currentSortOrderIndex = 0 private var currentSortOrderIndex = 0
protected lateinit var gocryptfsVolume: GocryptfsVolume protected var volumeId = -1
protected lateinit var encryptedVolume: EncryptedVolume
private lateinit var volumeName: String private lateinit var volumeName: String
private lateinit var explorerViewModel: ExplorerViewModel private lateinit var explorerViewModel: ExplorerViewModel
protected var currentDirectoryPath: String = "" protected var currentDirectoryPath: String = ""
@ -58,51 +69,92 @@ open class BaseExplorerActivity : BaseActivity() {
explorerViewModel.currentDirectoryPath = value explorerViewModel.currentDirectoryPath = value
} }
protected lateinit var fileOperationService: FileOperationService protected lateinit var fileOperationService: FileOperationService
protected val activityScope = MainScope()
private var directoryLoadingTask: Job? = null
protected lateinit var explorerElements: MutableList<ExplorerElement> protected lateinit var explorerElements: MutableList<ExplorerElement>
protected lateinit var explorerAdapter: ExplorerElementAdapter protected lateinit var explorerAdapter: ExplorerElementAdapter
private var isCreating = true protected lateinit var app: VolumeManagerApp
protected var isStartingActivity = false
private var usf_open = false private var usf_open = false
protected var usf_keep_open = false private lateinit var linearLayoutManager: LinearLayoutManager
private lateinit var toolbar: androidx.appcompat.widget.Toolbar private var isUsingListLayout = true
private lateinit var layoutIcon: ImageButton
private lateinit var titleText: TextView private lateinit var titleText: TextView
private lateinit var listExplorer: ListView private lateinit var recycler_view_explorer: RecyclerView
private lateinit var refresher: SwipeRefreshLayout private lateinit var refresher: SwipeRefreshLayout
private lateinit var loader: ProgressBar
private lateinit var textDirEmpty: TextView private lateinit var textDirEmpty: TextView
private lateinit var currentPathText: TextView private lateinit var currentPathText: TextView
private lateinit var numberOfFilesText: TextView
private lateinit var numberOfFoldersText: TextView
private lateinit var totalSizeText: TextView private lateinit var totalSizeText: TextView
protected val fileShare by lazy { FileShare(this) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
app = application as VolumeManagerApp
usf_open = sharedPrefs.getBoolean("usf_open", false) usf_open = sharedPrefs.getBoolean("usf_open", false)
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false) volumeName = intent.getStringExtra("volumeName") ?: ""
volumeName = intent.getStringExtra("volume_name") ?: "" volumeId = intent.getIntExtra("volumeId", -1)
val sessionID = intent.getIntExtra("sessionID", -1) encryptedVolume = app.volumeManager.getVolume(volumeId)!!.also { finishOnClose(it) }
gocryptfsVolume = GocryptfsVolume(sessionID)
sortOrderEntries = resources.getStringArray(R.array.sort_orders_entries) sortOrderEntries = resources.getStringArray(R.array.sort_orders_entries)
sortOrderValues = resources.getStringArray(R.array.sort_orders_values) sortOrderValues = resources.getStringArray(R.array.sort_orders_values)
foldersFirst = sharedPrefs.getBoolean("folders_first", true) foldersFirst = sharedPrefs.getBoolean("folders_first", true)
mapFolders = sharedPrefs.getBoolean("map_folders", true) mapFolders = sharedPrefs.getBoolean("map_folders", true)
currentSortOrderIndex = resources.getStringArray(R.array.sort_orders_values).indexOf(sharedPrefs.getString(ConstValues.sort_order_key, "name")) currentSortOrderIndex = resources.getStringArray(R.array.sort_orders_values).indexOf(sharedPrefs.getString(Constants.SORT_ORDER_KEY, "name"))
init() init()
toolbar = findViewById(R.id.toolbar) recycler_view_explorer = findViewById(R.id.recycler_view_explorer)
titleText = findViewById(R.id.title_text)
listExplorer = findViewById(R.id.list_explorer)
refresher = findViewById(R.id.refresher) refresher = findViewById(R.id.refresher)
loader = findViewById(R.id.loader)
textDirEmpty = findViewById(R.id.text_dir_empty) textDirEmpty = findViewById(R.id.text_dir_empty)
currentPathText = findViewById(R.id.current_path_text) currentPathText = findViewById(R.id.current_path_text)
numberOfFilesText = findViewById(R.id.number_of_files_text)
numberOfFoldersText = findViewById(R.id.number_of_folders_text)
totalSizeText = findViewById(R.id.total_size_text) totalSizeText = findViewById(R.id.total_size_text)
setSupportActionBar(toolbar) supportActionBar?.apply {
setDisplayShowCustomEnabled(true)
setCustomView(R.layout.action_bar)
titleText = customView.findViewById(R.id.title_text)
}
title = "" title = ""
titleText.text = getString(R.string.volume, volumeName) setVolumeNameTitle()
explorerAdapter = ExplorerElementAdapter(this) explorerAdapter = ExplorerElementAdapter(
explorerViewModel= ViewModelProvider(this).get(ExplorerViewModel::class.java) this,
if (sharedPrefs.getBoolean("thumbnails", true)) {
encryptedVolume
} else {
null
},
this,
sharedPrefs.getLong(Constants.THUMBNAIL_MAX_SIZE_KEY, Constants.DEFAULT_THUMBNAIL_MAX_SIZE)*1000,
)
explorerViewModel = ViewModelProvider(this).get(ExplorerViewModel::class.java)
currentDirectoryPath = explorerViewModel.currentDirectoryPath currentDirectoryPath = explorerViewModel.currentDirectoryPath
setCurrentPath(currentDirectoryPath) linearLayoutManager = LinearLayoutManager(this@BaseExplorerActivity)
listExplorer.apply { recycler_view_explorer.adapter = explorerAdapter
adapter = explorerAdapter isUsingListLayout = sharedPrefs.getBoolean("useListLayout", true)
onItemClickListener = OnItemClickListener { _, _, position, _ -> onExplorerItemClick(position) } layoutIcon = findViewById(R.id.layout_icon)
onItemLongClickListener = OnItemLongClickListener { _, _, position, _ -> onExplorerItemLongClick(position); true } setRecyclerViewLayout()
onBackPressedDispatcher.addCallback(this) {
if (explorerAdapter.selectedItems.isEmpty()) {
val parentPath = PathUtils.getParentPath(currentDirectoryPath)
if (parentPath == currentDirectoryPath) {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
} else {
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
}
} else {
unselectAll()
}
}
layoutIcon.setOnClickListener {
isUsingListLayout = !isUsingListLayout
setRecyclerViewLayout()
recycler_view_explorer.recycledViewPool.clear()
with (sharedPrefs.edit()) {
putBoolean("useListLayout", isUsingListLayout)
apply()
}
} }
refresher.setOnRefreshListener { refresher.setOnRefreshListener {
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath)
@ -112,186 +164,263 @@ open class BaseExplorerActivity : BaseActivity() {
} }
class ExplorerViewModel: ViewModel() { class ExplorerViewModel: ViewModel() {
var currentDirectoryPath = "" var currentDirectoryPath = "/"
}
private fun setRecyclerViewLayout() {
layoutIcon.setImageResource(if (isUsingListLayout) {
recycler_view_explorer.layoutManager = linearLayoutManager
explorerAdapter.isUsingListLayout = true
R.drawable.icon_view_grid
} else {
val displayMetrics = resources.displayMetrics
val columnsNumber = (displayMetrics.widthPixels / displayMetrics.density / 200 + 0.5).toInt()
recycler_view_explorer.layoutManager = GridLayoutManager(this, columnsNumber)
explorerAdapter.isUsingListLayout = false
R.drawable.icon_view_list
})
} }
protected open fun init() { protected open fun init() {
setContentView(R.layout.activity_explorer_base) setContentView(R.layout.activity_explorer)
} }
protected open fun bindFileOperationService(){ protected open fun bindFileOperationService() {
Intent(this, FileOperationService::class.java).also { FileOperationService.bind(this) {
bindService(it, object : ServiceConnection { fileOperationService = it
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as FileOperationService.LocalBinder
fileOperationService = binder.getService()
binder.setGocryptfsVolume(gocryptfsVolume)
}
override fun onServiceDisconnected(arg0: ComponentName) {
}
}, Context.BIND_AUTO_CREATE)
} }
} }
private fun startFileViewer(cls: Class<*>, filePath: String){ private fun startFileViewer(cls: Class<*>, filePath: String) {
val intent = Intent(this, cls).apply { val intent = Intent(this, cls).apply {
putExtra("path", filePath) putExtra("path", filePath)
putExtra("sessionID", gocryptfsVolume.sessionID) putExtra("volumeId", volumeId)
putExtra("sortOrder", sortOrderValues[currentSortOrderIndex]) putExtra("sortOrder", sortOrderValues[currentSortOrderIndex])
} }
isStartingActivity = true
startActivity(intent) startActivity(intent)
} }
private fun openWithExternalApp(fullPath: String){ protected fun onExportFailed(errorResId: Int) {
isStartingActivity = true CustomAlertDialogBuilder(this, theme)
ExternalProvider.open(this, gocryptfsVolume, fullPath) .setTitle(R.string.error)
.setMessage(getString(R.string.tmp_export_failed, getString(errorResId)))
.setPositiveButton(R.string.ok, null)
.show()
} }
protected open fun onExplorerItemClick(position: Int) { private fun openWithExternalApp(path: String, size: Long) {
val wasSelecting = explorerAdapter.selectedItems.isNotEmpty() app.isExporting = true
explorerAdapter.onItemClick(position) val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
if (explorerAdapter.selectedItems.isEmpty()) { if (exportedFile == null) {
if (!wasSelecting) { onExportFailed(R.string.export_failed_create)
val fullPath = explorerElements[position].fullPath return
when { }
explorerElements[position].isDirectory -> { val msg = when (exportedFile) {
setCurrentPath(fullPath) is EncryptedFileProvider.ExportedMemFile -> R.string.export_mem
} is EncryptedFileProvider.ExportedDiskFile -> R.string.export_disk
explorerElements[position].isParentFolder -> { else -> R.string.loading_msg_export
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath)) }
} object : LoadingTask<Pair<Intent?, Int?>>(this, theme, msg) {
isImage(fullPath) -> { override suspend fun doTask(): Pair<Intent?, Int?> {
startFileViewer(ImageViewer::class.java, fullPath) return fileShare.openWith(exportedFile, size, volumeId)
} }
isVideo(fullPath) -> { }.startTask(lifecycleScope) { (intent, error) ->
startFileViewer(VideoPlayer::class.java, fullPath) if (intent == null) {
} onExportFailed(error!!)
isText(fullPath) -> { } else {
startFileViewer(TextEditor::class.java, fullPath) app.isStartingExternalApp = true
} startActivity(intent)
isAudio(fullPath) -> { }
startFileViewer(AudioPlayer::class.java, fullPath) app.isExporting = false
} }
else -> { }
val adapter = OpenAsDialogAdapter(this, usf_open)
ColoredAlertDialogBuilder(this) private fun showOpenAsDialog(explorerElement: ExplorerElement) {
.setSingleChoiceItems(adapter, -1){ dialog, which -> val path = explorerElement.fullPath
when (adapter.getItem(which)){ val adapter = OpenAsDialogAdapter(this, usf_open)
"image" -> startFileViewer(ImageViewer::class.java, fullPath) CustomAlertDialogBuilder(this, theme)
"video" -> startFileViewer(VideoPlayer::class.java, fullPath) .setSingleChoiceItems(adapter, -1) { dialog, which ->
"audio" -> startFileViewer(AudioPlayer::class.java, fullPath) when (adapter.getItem(which)) {
"text" -> startFileViewer(TextEditor::class.java, fullPath) "image" -> startFileViewer(ImageViewer::class.java, path)
"external" -> if (usf_open){ "video" -> startFileViewer(VideoPlayer::class.java, path)
openWithExternalApp(fullPath) "audio" -> startFileViewer(AudioPlayer::class.java, path)
} "pdf" -> startFileViewer(PdfViewer::class.java, path)
} "text" -> startFileViewer(TextEditor::class.java, path)
dialog.dismiss() "external" -> if (usf_open) {
} openWithExternalApp(path, explorerElement.stat.size)
.setTitle(getString(R.string.open_as))
.setNegativeButton(R.string.cancel, null)
.show()
} }
} }
dialog.dismiss()
}
.setTitle(getString(R.string.open_as) + ':')
.setNegativeButton(R.string.cancel, null)
.show()
}
protected fun createNewFile(callback: (Long) -> Unit) {
EditTextDialog(this, R.string.enter_file_name) {
if (it.isEmpty()) {
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
createNewFile(callback)
} else {
val filePath = PathUtils.pathJoin(currentDirectoryPath, it)
val handleID = encryptedVolume.openFileWriteMode(filePath)
if (handleID == -1L) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.file_creation_failed)
.setPositiveButton(R.string.ok, null)
.show()
} else {
callback(handleID)
}
}
}.show()
}
private fun setVolumeNameTitle() {
titleText.text = getString(R.string.volume, volumeName)
}
override fun onSelectionChanged(size: Int) {
if (size == 0) {
setVolumeNameTitle()
} else {
titleText.text = getString(R.string.elements_selected, size, explorerElements.count { !it.isParentFolder })
}
}
override fun onExplorerElementClick(position: Int) {
if (explorerAdapter.selectedItems.isEmpty()) {
val fullPath = explorerElements[position].fullPath
when {
explorerElements[position].isDirectory -> {
setCurrentPath(fullPath)
}
explorerElements[position].isParentFolder -> {
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
}
FileTypes.isImage(fullPath) -> {
startFileViewer(ImageViewer::class.java, fullPath)
}
FileTypes.isVideo(fullPath) -> {
startFileViewer(VideoPlayer::class.java, fullPath)
}
FileTypes.isText(fullPath) -> {
startFileViewer(TextEditor::class.java, fullPath)
}
FileTypes.isPDF(fullPath) -> {
startFileViewer(PdfViewer::class.java, fullPath)
}
FileTypes.isAudio(fullPath) -> {
startFileViewer(AudioPlayer::class.java, fullPath)
}
else -> showOpenAsDialog(explorerElements[position])
} }
} }
invalidateOptionsMenu() invalidateOptionsMenu()
} }
protected open fun onExplorerItemLongClick(position: Int) { override fun onExplorerElementLongClick(position: Int) {
explorerAdapter.onItemLongClick(position)
invalidateOptionsMenu() invalidateOptionsMenu()
} }
protected fun unselectAll(){ protected fun unselectAll(notifyChange: Boolean = true) {
explorerAdapter.unSelectAll() explorerAdapter.unSelectAll(notifyChange)
invalidateOptionsMenu() invalidateOptionsMenu()
} }
private fun sortExplorerElements() { private fun displayExplorerElements() {
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements) ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
val sharedPrefsEditor = sharedPrefs.edit() unselectAll(false)
sharedPrefsEditor.putString(ConstValues.sort_order_key, sortOrderValues[currentSortOrderIndex]) loader.isVisible = false
sharedPrefsEditor.apply() recycler_view_explorer.isVisible = true
explorerAdapter.explorerElements = explorerElements
} }
protected fun setCurrentPath(path: String) { private suspend fun recursiveSetSize(directory: ExplorerElement) {
synchronized(this) { yield()
explorerElements = gocryptfsVolume.listDir(path) for (child in encryptedVolume.readDir(directory.fullPath) ?: return) {
if (child.isDirectory) {
recursiveSetSize(child)
}
directory.stat.size += child.stat.size
} }
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.INVISIBLE }
private fun displayNumberOfElements(textView: TextView, stringIdSingular: Int, stringIdPlural: Int, count: Int) {
with(textView) {
visibility = if (count == 0) {
View.GONE
} else {
text = if (count == 1) {
getString(stringIdSingular)
} else {
getString(stringIdPlural, count)
}
View.VISIBLE
}
}
}
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) = lifecycleScope.launch {
directoryLoadingTask?.cancelAndJoin()
recycler_view_explorer.isVisible = false
loader.isVisible = true
explorerElements = encryptedVolume.readDir(path) ?: return@launch
if (path != "/") {
explorerElements.add(
0,
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
)
}
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.GONE
currentDirectoryPath = path currentDirectoryPath = path
currentPathText.text = getString(R.string.location, currentDirectoryPath) currentPathText.text = getString(R.string.location, currentDirectoryPath)
Thread{ displayNumberOfElements(numberOfFilesText, R.string.one_file, R.string.multiple_files, explorerElements.count { it.isRegularFile })
val totalSizeValue = if (mapFolders) { displayNumberOfElements(numberOfFoldersText, R.string.one_folder, R.string.multiple_folders, explorerElements.count { it.isDirectory })
var totalSize: Long = 0 if (mapFolders) {
synchronized(this) { var totalSize: Long = 0
for (element in explorerElements){ directoryLoadingTask = launch(Dispatchers.IO) {
if (element.isDirectory){ for (element in explorerElements) {
var dirSize: Long = 0 if (element.isDirectory) {
for (subFile in gocryptfsVolume.recursiveMapFiles(element.fullPath)){ recursiveSetSize(element)
if (subFile.isRegularFile){
dirSize += subFile.size
}
}
element.size = dirSize
totalSize += dirSize
} else if (element.isRegularFile) {
totalSize += element.size
}
} }
totalSize += element.stat.size
} }
PathUtils.formatSize(totalSize)
} else {
getString(R.string.default_total_size)
} }
runOnUiThread { directoryLoadingTask!!.join()
totalSizeText.text = getString(R.string.total_size, totalSizeValue) displayExplorerElements()
synchronized(this) { totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
sortExplorerElements() onDisplayed?.invoke()
} } else {
if (path.isNotEmpty()) { //not root displayExplorerElements()
synchronized(this) { totalSizeText.text = getString(
explorerElements.add( R.string.total_size,
0, PathUtils.formatSize(explorerElements.filter { !it.isParentFolder }.sumOf { it.stat.size })
ExplorerElement("..", (-1).toShort(), parentPath = currentDirectoryPath) )
) onDisplayed?.invoke()
} }
}
explorerAdapter.setExplorerElements(explorerElements)
}
}.start()
} }
private fun askCloseVolume() { private fun askLockVolume() {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage(R.string.ask_close_volume) .setMessage(R.string.ask_lock_volume)
.setPositiveButton(R.string.ok) { _, _ -> closeVolumeOnUserExit() } .setPositiveButton(R.string.ok) { _, _ ->
app.volumeManager.closeVolume(volumeId)
finish()
}
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
override fun onBackPressed() {
if (explorerAdapter.selectedItems.isEmpty()) {
val parentPath = PathUtils.getParentPath(currentDirectoryPath)
if (parentPath == currentDirectoryPath) {
askCloseVolume()
} else {
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
}
} else {
unselectAll()
}
}
private fun createFolder(folderName: String){ private fun createFolder(folderName: String){
if (folderName.isEmpty()) { if (folderName.isEmpty()) {
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
} else { } else {
if (!gocryptfsVolume.mkdir(PathUtils.pathJoin(currentDirectoryPath, folderName))) { if (!encryptedVolume.mkdir(PathUtils.pathJoin(currentDirectoryPath, folderName))) {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.error_mkdir) .setMessage(R.string.error_mkdir)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@ -304,85 +433,62 @@ open class BaseExplorerActivity : BaseActivity() {
} }
protected fun openDialogCreateFolder() { protected fun openDialogCreateFolder() {
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null) EditTextDialog(this, R.string.enter_folder_name) {
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text) createFolder(it)
val dialog = ColoredAlertDialogBuilder(this) }.show()
.setView(dialogEditTextView)
.setTitle(R.string.enter_folder_name)
.setPositiveButton(R.string.ok) { _, _ ->
val folderName = dialogEditText.text.toString()
createFolder(folderName)
}
.setNegativeButton(R.string.cancel, null)
.create()
dialogEditText.setOnEditorActionListener { _, _, _ ->
val folderName = dialogEditText.text.toString()
dialog.dismiss()
createFolder(folderName)
true
}
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
dialog.show()
} }
protected fun checkPathOverwrite(items: ArrayList<OperationFile>, dstDirectoryPath: String, callback: (ArrayList<OperationFile>?) -> Unit) { protected fun checkPathOverwrite(items: List<OperationFile>, dstDirectoryPath: String, callback: (List<OperationFile>?) -> Unit) {
val srcDirectoryPath = items[0].explorerElement.parentPath val srcDirectoryPath = items[0].parentPath
var ready = true var ready = true
for (i in 0 until items.size) { for (i in items.indices) {
val testDstPath: String val testDstPath: String
if (items[i].dstPath == null){ if (items[i].dstPath == null){
testDstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].explorerElement.fullPath)) testDstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].srcPath))
if (gocryptfsVolume.pathExists(testDstPath)){ if (encryptedVolume.pathExists(testDstPath)) {
ready = false ready = false
} else { } else {
items[i].dstPath = testDstPath items[i].dstPath = testDstPath
} }
} else { } else {
testDstPath = items[i].dstPath!! testDstPath = items[i].dstPath!!
if (gocryptfsVolume.pathExists(testDstPath) && !items[i].overwriteConfirmed){ if (encryptedVolume.pathExists(testDstPath) && !items[i].overwriteConfirmed) {
ready = false ready = false
} }
} }
if (!ready){ if (!ready){
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage(getString(if (items[i].explorerElement.isDirectory){R.string.dir_overwrite_question} else {R.string.file_overwrite_question}, testDstPath)) .setMessage(getString(
if (items[i].isDirectory) {
R.string.dir_overwrite_question
} else {
R.string.file_overwrite_question
}, testDstPath
))
.setPositiveButton(R.string.yes) {_, _ -> .setPositiveButton(R.string.yes) {_, _ ->
items[i].dstPath = testDstPath items[i].dstPath = testDstPath
items[i].overwriteConfirmed = true items[i].overwriteConfirmed = true
checkPathOverwrite(items, dstDirectoryPath, callback) checkPathOverwrite(items, dstDirectoryPath, callback)
} }
.setNegativeButton(R.string.no) { _, _ -> .setNegativeButton(R.string.no) { _, _ ->
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null) with(EditTextDialog(this, R.string.enter_new_name) {
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text) items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].parentPath), it)
dialogEditText.setText(items[i].explorerElement.name) if (items[i].isDirectory) {
dialogEditText.selectAll() for (j in items.indices) {
val dialog = ColoredAlertDialogBuilder(this) if (PathUtils.isChildOf(items[j].srcPath, items[i].srcPath)) {
.setView(dialogEditTextView) items[j].dstPath = PathUtils.pathJoin(items[i].dstPath!!, PathUtils.getRelativePath(items[i].srcPath, items[j].srcPath))
.setTitle(R.string.enter_new_name)
.setPositiveButton(R.string.ok) { _, _ ->
items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].explorerElement.parentPath), dialogEditText.text.toString())
if (items[i].explorerElement.isDirectory){
for (j in 0 until items.size){
if (PathUtils.isChildOf(items[j].explorerElement.fullPath, items[i].explorerElement.fullPath)){
items[j].dstPath = PathUtils.pathJoin(items[i].dstPath!!, PathUtils.getRelativePath(items[i].explorerElement.fullPath, items[j].explorerElement.fullPath))
}
}
} }
checkPathOverwrite(items, dstDirectoryPath, callback)
} }
.setOnCancelListener{ }
callback(null)
}
.create()
dialogEditText.setOnEditorActionListener { _, _, _ ->
dialog.dismiss()
items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].explorerElement.parentPath), dialogEditText.text.toString())
checkPathOverwrite(items, dstDirectoryPath, callback) checkPathOverwrite(items, dstDirectoryPath, callback)
true }) {
setSelectedText(items[i].name)
setOnCancelListener{
callback(null)
}
show()
} }
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
dialog.show()
} }
.setOnCancelListener{ .setOnCancelListener{
callback(null) callback(null)
@ -396,12 +502,38 @@ open class BaseExplorerActivity : BaseActivity() {
} }
} }
protected fun importFilesFromUris(uris: List<Uri>, callback: (String?) -> Unit) { protected fun onTaskResult(
result: TaskResult<out String?>,
failedErrorMessage: Int,
successMessage: Int = -1,
onSuccess: (() -> Unit)? = null,
) {
when (result.state) {
TaskResult.State.SUCCESS -> {
if (onSuccess == null) {
Toast.makeText(this, successMessage, Toast.LENGTH_SHORT).show()
} else {
onSuccess()
}
}
TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(failedErrorMessage, result.failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
TaskResult.State.ERROR -> result.showErrorAlertDialog(this, theme)
TaskResult.State.CANCELLED -> {}
}
}
protected fun importFilesFromUris(uris: List<Uri>, callback: () -> Unit) {
val items = ArrayList<OperationFile>() val items = ArrayList<OperationFile>()
for (uri in uris) { for (uri in uris) {
val fileName = PathUtils.getFilenameFromURI(this, uri) val fileName = PathUtils.getFilenameFromURI(this, uri)
if (fileName == null) { if (fileName == null) {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(getString(R.string.error_retrieving_filename, uri)) .setMessage(getString(R.string.error_retrieving_filename, uri))
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@ -409,30 +541,16 @@ open class BaseExplorerActivity : BaseActivity() {
items.clear() items.clear()
break break
} else { } else {
items.add(OperationFile.fromExplorerElement(ExplorerElement(fileName, 1, parentPath = currentDirectoryPath))) items.add(OperationFile(PathUtils.pathJoin(currentDirectoryPath, fileName), Stat.S_IFREG))
} }
} }
if (items.size > 0) { if (items.size > 0) {
checkPathOverwrite(items, currentDirectoryPath) { checkedItems -> checkPathOverwrite(items, currentDirectoryPath) { checkedItems ->
checkedItems?.let { checkedItems?.let {
fileOperationService.importFilesFromUris(checkedItems.map { it.dstPath!! }, uris){ failedItem -> activityScope.launch {
runOnUiThread { val result = fileOperationService.importFilesFromUris(volumeId, checkedItems.map { it.dstPath!! }, uris)
callback(failedItem) onTaskResult(result, R.string.import_failed, onSuccess = callback)
} setCurrentPath(currentDirectoryPath)
}
}
}
}
}
fun importDirectory(sourceUri: Uri, callback: (String?, List<Uri>, DocumentFile) -> Unit) {
val tree = DocumentFile.fromTreeUri(this, sourceUri)!! //non-null after Lollipop
val operation = OperationFile.fromExplorerElement(ExplorerElement(tree.name!!, 0, parentPath = currentDirectoryPath))
checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation ->
checkedOperation?.let {
fileOperationService.importDirectory(checkedOperation[0].dstPath!!, tree) { failedItem, uris ->
runOnUiThread {
callback(failedItem, uris, tree)
} }
} }
} }
@ -443,38 +561,47 @@ open class BaseExplorerActivity : BaseActivity() {
if (new_name.isEmpty()) { if (new_name.isEmpty()) {
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
} else { } else {
if (!gocryptfsVolume.rename(PathUtils.pathJoin(currentDirectoryPath, old_name), PathUtils.pathJoin(currentDirectoryPath, new_name))) { if (!encryptedVolume.rename(PathUtils.pathJoin(currentDirectoryPath, old_name), PathUtils.pathJoin(currentDirectoryPath, new_name))) {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(getString(R.string.rename_failed, old_name)) .setMessage(getString(R.string.rename_failed, old_name))
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} else { } else {
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath) {
invalidateOptionsMenu() invalidateOptionsMenu()
}
} }
} }
} }
protected fun handleMenuItems(menu: Menu){ override fun onCreateOptionsMenu(menu: Menu): Boolean {
menu.findItem(R.id.rename).isVisible = false menu.findItem(R.id.rename).isVisible = false
menu.findItem(R.id.open_as)?.isVisible = false
if (usf_open){ if (usf_open){
menu.findItem(R.id.external_open)?.isVisible = false menu.findItem(R.id.external_open)?.isVisible = false
} }
val noItemSelected = explorerAdapter.selectedItems.isEmpty() val noItemSelected = explorerAdapter.selectedItems.isEmpty()
with(UIUtils.getMenuIconNeutralTint(this, menu)) {
applyTo(R.id.sort, R.drawable.icon_sort)
applyTo(R.id.share, R.drawable.icon_share)
}
menu.findItem(R.id.sort).isVisible = noItemSelected menu.findItem(R.id.sort).isVisible = noItemSelected
menu.findItem(R.id.lock).isVisible = noItemSelected
menu.findItem(R.id.close).isVisible = noItemSelected menu.findItem(R.id.close).isVisible = noItemSelected
if (noItemSelected){ supportActionBar?.setDisplayHomeAsUpEnabled(!noItemSelected)
toolbar.navigationIcon = null if (!noItemSelected) {
} else {
toolbar.setNavigationIcon(R.drawable.icon_arrow_back)
if (explorerAdapter.selectedItems.size == 1) { if (explorerAdapter.selectedItems.size == 1) {
menu.findItem(R.id.rename).isVisible = true menu.findItem(R.id.rename).isVisible = true
if (usf_open && explorerElements[explorerAdapter.selectedItems[0]].isRegularFile) { if (explorerElements[explorerAdapter.selectedItems.first()].isRegularFile) {
menu.findItem(R.id.external_open)?.isVisible = true menu.findItem(R.id.open_as)?.isVisible = true
if (usf_open) {
menu.findItem(R.id.external_open)?.isVisible = true
}
} }
} }
} }
return super.onCreateOptionsMenu(menu)
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -484,11 +611,17 @@ open class BaseExplorerActivity : BaseActivity() {
true true
} }
R.id.sort -> { R.id.sort -> {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.sort_order) .setTitle(R.string.sort_order)
.setSingleChoiceItems(DialogSingleChoiceAdapter(this, sortOrderEntries.toList()), currentSortOrderIndex) { dialog, which -> .setSingleChoiceItems(sortOrderEntries, currentSortOrderIndex) { dialog, which ->
currentSortOrderIndex = which currentSortOrderIndex = which
setCurrentPath(currentDirectoryPath) // displayExplorerElements must not be called if directoryLoadingTask is active
if (directoryLoadingTask?.isActive != true) {
displayExplorerElements()
}
val sharedPrefsEditor = sharedPrefs.edit()
sharedPrefsEditor.putString(Constants.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
sharedPrefsEditor.apply()
dialog.dismiss() dialog.dismiss()
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
@ -496,86 +629,51 @@ open class BaseExplorerActivity : BaseActivity() {
true true
} }
R.id.rename -> { R.id.rename -> {
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null) val oldName = explorerElements[explorerAdapter.selectedItems.first()].name
val oldName = explorerElements[explorerAdapter.selectedItems[0]].name with(EditTextDialog(this, R.string.rename_title) {
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text) rename(oldName, it)
dialogEditText.setText(oldName) }) {
dialogEditText.selectAll() setSelectedText(oldName)
val dialog = ColoredAlertDialogBuilder(this) show()
.setView(dialogEditTextView)
.setTitle(R.string.rename_title)
.setPositiveButton(R.string.ok) { _, _ ->
val newName = dialogEditText.text.toString()
rename(oldName, newName)
}
.setNegativeButton(R.string.cancel, null)
.create()
dialogEditText.setOnEditorActionListener { _, _, _ ->
val newName = dialogEditText.text.toString()
dialog.dismiss()
rename(oldName, newName)
true
} }
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) true
dialog.show() }
R.id.open_as -> {
showOpenAsDialog(explorerElements[explorerAdapter.selectedItems.first()])
true true
} }
R.id.external_open -> { R.id.external_open -> {
if (usf_open){ if (usf_open){
openWithExternalApp(PathUtils.pathJoin(currentDirectoryPath, explorerElements[explorerAdapter.selectedItems[0]].name)) val explorerElement = explorerElements[explorerAdapter.selectedItems.first()]
openWithExternalApp(explorerElement.fullPath, explorerElement.stat.size)
unselectAll() unselectAll()
} }
true true
} }
R.id.close -> { R.id.close -> {
askCloseVolume() finish()
true
}
R.id.lock -> {
askLockVolume()
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
} }
protected open fun closeVolumeOnUserExit() {
finish()
}
protected open fun closeVolumeOnDestroy() {
if (!gocryptfsVolume.isClosed()){
gocryptfsVolume.close()
}
RestrictedFileProvider.wipeAll(this) //additional security
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
if (!isChangingConfigurations) { //activity won't be recreated if (!isChangingConfigurations) { //activity won't be recreated
closeVolumeOnDestroy() activityScope.cancel()
}
}
override fun onPause() {
super.onPause()
if (!isChangingConfigurations){
if (isStartingActivity){
isStartingActivity = false
} else if (!usf_keep_open){
finish()
}
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (isCreating){ if (app.isStartingExternalApp) {
isCreating = false TemporaryFileProvider.instance.wipe()
} else {
if (gocryptfsVolume.isClosed()){
finish()
} else {
isStartingActivity = false
ExternalProvider.removeFiles(this)
setCurrentPath(currentDirectoryPath)
}
} }
setCurrentPath(currentDirectoryPath)
} }
} }

View File

@ -5,22 +5,23 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.WindowManager
import android.widget.EditText
import android.widget.Toast import android.widget.Toast
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.CameraActivity import sushi.hardcore.droidfs.CameraActivity
import sushi.hardcore.droidfs.GocryptfsVolume import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.OpenActivity import sushi.hardcore.droidfs.MainActivity
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.adapters.IconTextDialogAdapter import sushi.hardcore.droidfs.adapters.IconTextDialogAdapter
import sushi.hardcore.droidfs.content_providers.ExternalProvider
import sushi.hardcore.droidfs.databinding.ActivityExplorerBinding
import sushi.hardcore.droidfs.file_operations.OperationFile import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import sushi.hardcore.droidfs.widgets.EditTextDialog
class ExplorerActivity : BaseExplorerActivity() { class ExplorerActivity : BaseExplorerActivity() {
companion object { companion object {
@ -31,201 +32,185 @@ class ExplorerActivity : BaseExplorerActivity() {
private var usf_share = false private var usf_share = false
private var currentItemAction = ItemsActions.NONE private var currentItemAction = ItemsActions.NONE
private val itemsToProcess = ArrayList<OperationFile>() private val itemsToProcess = ArrayList<OperationFile>()
private lateinit var binding: ActivityExplorerBinding
private val pickFromOtherVolumes = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val pickFromOtherVolumes = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
result.data?.let { resultIntent -> result.data?.let { resultIntent ->
val remoteSessionID = resultIntent.getIntExtra("sessionID", -1) val srcVolumeId = resultIntent.getIntExtra("volumeId", -1)
val remoteGocryptfsVolume = GocryptfsVolume(remoteSessionID) val srcEncryptedVolume = app.volumeManager.getVolume(srcVolumeId)!!
val path = resultIntent.getStringExtra("path") val path = resultIntent.getStringExtra("path")
val operationFiles = ArrayList<OperationFile>()
if (path == null){ //multiples elements if (path == null){ //multiples elements
val paths = resultIntent.getStringArrayListExtra("paths") val paths = resultIntent.getStringArrayListExtra("paths")
val types = resultIntent.getIntegerArrayListExtra("types") val types = resultIntent.getIntegerArrayListExtra("types")
if (types != null && paths != null){ if (types != null && paths != null){
for (i in paths.indices) { object : LoadingTask<List<OperationFile>>(this, theme, R.string.discovering_files) {
operationFiles.add( override suspend fun doTask(): List<OperationFile> {
OperationFile.fromExplorerElement( val operationFiles = ArrayList<OperationFile>()
ExplorerElement(File(paths[i]).name, types[i].toShort(), parentPath = PathUtils.getParentPath(paths[i])) for (i in paths.indices) {
) operationFiles.add(OperationFile(paths[i], types[i]))
) if (types[i] == Stat.S_IFDIR) {
if (types[i] == 0){ //directory srcEncryptedVolume.recursiveMapFiles(paths[i])?.forEach {
remoteGocryptfsVolume.recursiveMapFiles(paths[i]).forEach { operationFiles.add(OperationFile.fromExplorerElement(it))
operationFiles.add(OperationFile.fromExplorerElement(it)) }
}
}
}
}
} else {
operationFiles.add(
OperationFile.fromExplorerElement(
ExplorerElement(File(path).name, 1, parentPath = PathUtils.getParentPath(path))
)
)
}
if (operationFiles.size > 0){
checkPathOverwrite(operationFiles, currentDirectoryPath) { items ->
if (items == null) {
remoteGocryptfsVolume.close()
} else {
fileOperationService.copyElements(items, remoteGocryptfsVolume){ failedItem ->
runOnUiThread {
if (failedItem == null){
Toast.makeText(this, R.string.success_import, Toast.LENGTH_SHORT).show()
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
} }
setCurrentPath(currentDirectoryPath)
} }
remoteGocryptfsVolume.close() return operationFiles
} }
}.startTask(lifecycleScope) { operationFiles ->
importFilesFromVolume(srcVolumeId, operationFiles)
} }
} }
} else { } else {
remoteGocryptfsVolume.close() importFilesFromVolume(srcVolumeId, arrayListOf(OperationFile(path, Stat.S_IFREG)))
} }
} }
} }
} }
private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris != null) { if (uris != null) {
importFilesFromUris(uris){ failedItem -> for (uri in uris) {
onImportComplete(failedItem, uris) try {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
} catch (e: SecurityException) {
e.printStackTrace()
}
}
importFilesFromUris(uris) {
onImportComplete(uris)
} }
} }
} }
private val pickExportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> private val pickExportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) { if (uri != null) {
fileOperationService.exportFiles(uri, explorerAdapter.selectedItems.map { i -> explorerElements[i] }){ failedItem -> contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
runOnUiThread { val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
if (failedItem == null){ activityScope.launch {
Toast.makeText(this, R.string.success_export, Toast.LENGTH_SHORT).show() val result = fileOperationService.exportFiles(volumeId, items, uri)
} else { onTaskResult(result, R.string.export_failed, R.string.success_export)
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(getString(R.string.export_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
}
} }
} }
unselectAll() unselectAll()
} }
private val pickImportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { rootUri -> private val pickImportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { rootUri ->
rootUri?.let { rootUri?.let {
importDirectory(it, ::onImportComplete) contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
val tree = DocumentFile.fromTreeUri(this, it)!! //non-null after Lollipop
val operation = OperationFile(PathUtils.pathJoin(currentDirectoryPath, tree.name!!), Stat.S_IFDIR)
checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation ->
checkedOperation?.let {
activityScope.launch {
val result = fileOperationService.importDirectory(volumeId, checkedOperation[0].dstPath!!, tree)
onTaskResult(result.taskResult, R.string.import_failed) {
onImportComplete(result.uris, tree)
}
setCurrentPath(currentDirectoryPath)
}
}
}
} }
} }
private fun onImportComplete(failedItem: String?, urisToWipe: List<Uri>, rootFile: DocumentFile? = null) { private fun importFilesFromVolume(srcVolumeId: Int, operationFiles: List<OperationFile>) {
if (failedItem == null){ checkPathOverwrite(operationFiles, currentDirectoryPath) { items ->
ColoredAlertDialogBuilder(this) if (items != null) {
.setTitle(R.string.success_import) // stop loading thumbnails while writing files
.setMessage(""" explorerAdapter.loadThumbnails = false
${getString(R.string.success_import_msg)} activityScope.launch {
${getString(R.string.ask_for_wipe)} onTaskResult(
""".trimIndent()) fileOperationService.copyElements(
.setPositiveButton(R.string.yes) { _, _ -> volumeId,
fileOperationService.wipeUris(urisToWipe, rootFile) { errorMsg -> items,
runOnUiThread { srcVolumeId
if (errorMsg == null){ ), R.string.import_failed, R.string.success_import
Toast.makeText(this, R.string.wipe_successful, Toast.LENGTH_SHORT).show() )
} else { explorerAdapter.loadThumbnails = true
ColoredAlertDialogBuilder(this) setCurrentPath(currentDirectoryPath)
.setTitle(R.string.error)
.setMessage(getString(R.string.wipe_failed, errorMsg))
.setPositiveButton(R.string.ok, null)
.show()
}
}
}
} }
.setNegativeButton(R.string.no, null) }
.show()
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
} }
setCurrentPath(currentDirectoryPath)
}
private fun onImportComplete(urisToWipe: List<Uri>, rootFile: DocumentFile? = null) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage("""
${getString(R.string.success_import_msg)}
${getString(R.string.ask_for_wipe)}
""".trimIndent())
.setPositiveButton(R.string.yes) { _, _ ->
activityScope.launch {
onTaskResult(
fileOperationService.wipeUris(urisToWipe, rootFile),
R.string.wipe_failed,
R.string.wipe_successful,
)
}
}
.setNegativeButton(R.string.no, null)
.show()
} }
override fun init() { override fun init() {
binding = ActivityExplorerBinding.inflate(layoutInflater) super.init()
setContentView(binding.root) onBackPressedDispatcher.addCallback(this) {
binding.fab.setOnClickListener { if (currentItemAction != ItemsActions.NONE) {
cancelItemAction()
invalidateOptionsMenu()
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
findViewById<FloatingActionButton>(R.id.fab).setOnClickListener {
if (currentItemAction != ItemsActions.NONE){ if (currentItemAction != ItemsActions.NONE){
openDialogCreateFolder() openDialogCreateFolder()
} else { } else {
val adapter = IconTextDialogAdapter(this) val adapter = IconTextDialogAdapter(this)
adapter.items = listOf( adapter.items = listOf(
listOf("importFromOtherVolumes", R.string.import_from_other_volume, R.drawable.icon_transfert), listOf("importFromOtherVolumes", R.string.import_from_other_volume, R.drawable.icon_transfer),
listOf("importFiles", R.string.import_files, R.drawable.icon_encrypt), listOf("importFiles", R.string.import_files, R.drawable.icon_encrypt),
listOf("importFolder", R.string.import_folder, R.drawable.icon_import_folder), listOf("importFolder", R.string.import_folder, R.drawable.icon_import_folder),
listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown), listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown),
listOf("createFolder", R.string.mkdir, R.drawable.icon_create_new_folder), listOf("createFolder", R.string.mkdir, R.drawable.icon_create_new_folder),
listOf("takePhoto", R.string.take_photo, R.drawable.icon_camera) listOf("camera", R.string.camera, R.drawable.icon_photo)
) )
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setSingleChoiceItems(adapter, -1){ thisDialog, which -> .setSingleChoiceItems(adapter, -1){ thisDialog, which ->
when (adapter.getItem(which)){ when (adapter.getItem(which)){
"importFromOtherVolumes" -> { "importFromOtherVolumes" -> {
val intent = Intent(this, OpenActivity::class.java) val intent = Intent(this, MainActivity::class.java)
intent.action = "pick" intent.action = "pick"
intent.putExtra("sessionID", gocryptfsVolume.sessionID)
isStartingActivity = true
pickFromOtherVolumes.launch(intent) pickFromOtherVolumes.launch(intent)
} }
"importFiles" -> { "importFiles" -> {
isStartingActivity = true app.isStartingExternalApp = true
pickFiles.launch(arrayOf("*/*")) pickFiles.launch(arrayOf("*/*"))
} }
"importFolder" -> { "importFolder" -> {
isStartingActivity = true app.isStartingExternalApp = true
pickImportDirectory.launch(null) pickImportDirectory.launch(null)
} }
"createFile" -> { "createFile" -> {
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null) createNewFile {
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text) encryptedVolume.closeFile(it)
val dialog = ColoredAlertDialogBuilder(this) setCurrentPath(currentDirectoryPath)
.setView(dialogEditTextView) invalidateOptionsMenu()
.setTitle(getString(R.string.enter_file_name))
.setPositiveButton(R.string.ok) { _, _ ->
val fileName = dialogEditText.text.toString()
createNewFile(fileName)
}
.setNegativeButton(R.string.cancel, null)
.create()
dialogEditText.setOnEditorActionListener { _, _, _ ->
val fileName = dialogEditText.text.toString()
dialog.dismiss()
createNewFile(fileName)
true
} }
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
dialog.show()
} }
"createFolder" -> { "createFolder" -> {
openDialogCreateFolder() openDialogCreateFolder()
} }
"takePhoto" -> { "camera" -> {
val intent = Intent(this, CameraActivity::class.java) val intent = Intent(this, CameraActivity::class.java)
intent.putExtra("path", currentDirectoryPath) intent.putExtra("path", currentDirectoryPath)
intent.putExtra("sessionID", gocryptfsVolume.sessionID) intent.putExtra("volumeId", volumeId)
isStartingActivity = true
startActivity(intent) startActivity(intent)
} }
} }
thisDialog.dismiss() thisDialog.dismiss()
} }
.setTitle(getString(R.string.fab_dialog_title)) .setTitle(getString(R.string.add))
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
@ -234,39 +219,20 @@ class ExplorerActivity : BaseExplorerActivity() {
usf_share = sharedPrefs.getBoolean("usf_share", false) usf_share = sharedPrefs.getBoolean("usf_share", false)
} }
override fun onExplorerItemLongClick(position: Int) { override fun onExplorerElementLongClick(position: Int) {
super.onExplorerElementLongClick(position)
cancelItemAction() cancelItemAction()
explorerAdapter.onItemLongClick(position)
invalidateOptionsMenu()
}
private fun createNewFile(fileName: String){
if (fileName.isEmpty()) {
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
} else {
val filePath = PathUtils.pathJoin(currentDirectoryPath, fileName)
val handleID = gocryptfsVolume.openWriteMode(filePath) //don't check overwrite because openWriteMode open in read-write (doesn't erase content)
if (handleID == -1) {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.file_creation_failed)
.setPositiveButton(R.string.ok, null)
.show()
} else {
gocryptfsVolume.closeFile(handleID)
setCurrentPath(currentDirectoryPath)
invalidateOptionsMenu()
}
}
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.explorer, menu) menuInflater.inflate(R.menu.explorer, menu)
val result = super.onCreateOptionsMenu(menu)
if (currentItemAction != ItemsActions.NONE) { if (currentItemAction != ItemsActions.NONE) {
menu.findItem(R.id.validate).isVisible = true menu.findItem(R.id.validate).isVisible = true
menu.findItem(R.id.lock).isVisible = false
menu.findItem(R.id.close).isVisible = false menu.findItem(R.id.close).isVisible = false
supportActionBar?.setDisplayHomeAsUpEnabled(true)
} else { } else {
handleMenuItems(menu)
if (usf_share){ if (usf_share){
menu.findItem(R.id.share).isVisible = false menu.findItem(R.id.share).isVisible = false
} }
@ -289,7 +255,7 @@ class ExplorerActivity : BaseExplorerActivity() {
} }
} }
} }
return true return result
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -314,11 +280,6 @@ class ExplorerActivity : BaseExplorerActivity() {
R.id.copy -> { R.id.copy -> {
for (i in explorerAdapter.selectedItems){ for (i in explorerAdapter.selectedItems){
itemsToProcess.add(OperationFile.fromExplorerElement(explorerElements[i])) itemsToProcess.add(OperationFile.fromExplorerElement(explorerElements[i]))
if (explorerElements[i].isDirectory){
gocryptfsVolume.recursiveMapFiles(explorerElements[i].fullPath).forEach {
itemsToProcess.add(OperationFile.fromExplorerElement(it))
}
}
} }
currentItemAction = ItemsActions.COPY currentItemAction = ItemsActions.COPY
unselectAll() unselectAll()
@ -326,77 +287,113 @@ class ExplorerActivity : BaseExplorerActivity() {
} }
R.id.validate -> { R.id.validate -> {
if (currentItemAction == ItemsActions.COPY){ if (currentItemAction == ItemsActions.COPY){
checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items -> object : LoadingTask<List<OperationFile>>(this, theme, R.string.discovering_files) {
items?.let { override suspend fun doTask(): List<OperationFile> {
fileOperationService.copyElements(it.toMutableList() as ArrayList<OperationFile>){ failedItem -> val items = itemsToProcess.toMutableList()
runOnUiThread { itemsToProcess.filter { it.isDirectory }.forEach { dir ->
if (failedItem == null){ encryptedVolume.recursiveMapFiles(dir.srcPath)?.forEach {
Toast.makeText(this, R.string.copy_success, Toast.LENGTH_SHORT).show() items.add(OperationFile.fromExplorerElement(it))
} else { }
ColoredAlertDialogBuilder(this) }
.setTitle(R.string.error) return items
.setMessage(getString(R.string.copy_failed, failedItem)) }
.setPositiveButton(R.string.ok, null) }.startTask(lifecycleScope) { items ->
.show() checkPathOverwrite(items, currentDirectoryPath) {
} it?.let { checkedItems ->
activityScope.launch {
onTaskResult(
fileOperationService.copyElements(volumeId, checkedItems),
R.string.copy_failed,
R.string.copy_success,
)
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath)
} }
} }
cancelItemAction()
invalidateOptionsMenu()
} }
cancelItemAction()
unselectAll()
} }
} else if (currentItemAction == ItemsActions.MOVE){ } else if (currentItemAction == ItemsActions.MOVE){
mapFileForMove(itemsToProcess, itemsToProcess[0].explorerElement.parentPath) itemsToProcess.forEach {
checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items -> it.dstPath = PathUtils.pathJoin(currentDirectoryPath, it.name)
items?.let { it.overwriteConfirmed = false // reset the field in case of a previous cancelled move
fileOperationService.moveElements(it.toMutableList() as ArrayList<OperationFile>){ failedItem -> }
runOnUiThread { val toMove = ArrayList<OperationFile>(itemsToProcess.size)
if (failedItem == null){ val toClean = ArrayList<String>()
Toast.makeText(this, R.string.move_success, Toast.LENGTH_SHORT).show() prepareFilesForMove(
} else { itemsToProcess,
ColoredAlertDialogBuilder(this) toMove,
.setTitle(R.string.error) toClean,
.setMessage(getString(R.string.move_failed, failedItem)) ) {
.setPositiveButton(R.string.ok, null) activityScope.launch {
.show() onTaskResult(
} fileOperationService.moveElements(volumeId, toMove, toClean),
setCurrentPath(currentDirectoryPath) R.string.move_success,
} R.string.move_failed,
} )
setCurrentPath(currentDirectoryPath)
} }
cancelItemAction() cancelItemAction()
unselectAll() invalidateOptionsMenu()
} }
} }
true true
} }
R.id.delete -> { R.id.delete -> {
val size = explorerAdapter.selectedItems.size val size = explorerAdapter.selectedItems.size
val dialog = ColoredAlertDialogBuilder(this) val dialog = CustomAlertDialogBuilder(this, theme)
dialog.setTitle(R.string.warning) dialog.setTitle(R.string.warning)
dialog.setPositiveButton(R.string.ok) { _, _ -> removeSelectedItems() } dialog.setPositiveButton(R.string.ok) { _, _ ->
val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
activityScope.launch {
fileOperationService.removeElements(volumeId, items)?.let { failedItem ->
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
setCurrentPath(currentDirectoryPath) //refresh
}
unselectAll()
}
dialog.setNegativeButton(R.string.cancel, null) dialog.setNegativeButton(R.string.cancel, null)
if (size > 1) { if (size > 1) {
dialog.setMessage(getString(R.string.multiple_delete_confirm, explorerAdapter.selectedItems.size.toString())) dialog.setMessage(getString(R.string.multiple_delete_confirm, explorerAdapter.selectedItems.size.toString()))
} else { } else {
dialog.setMessage(getString(R.string.single_delete_confirm, explorerAdapter.getItem(explorerAdapter.selectedItems[0]).name)) dialog.setMessage(getString(
R.string.single_delete_confirm,
explorerAdapter.explorerElements[explorerAdapter.selectedItems.first()].name
))
} }
dialog.show() dialog.show()
true true
} }
R.id.share -> { R.id.share -> {
val paths: MutableList<String> = ArrayList() val files = explorerAdapter.selectedItems.map { i ->
for (i in explorerAdapter.selectedItems) { explorerElements[i].let {
paths.add(explorerElements[i].fullPath) Pair(it.fullPath, it.stat.size)
}
}
app.isExporting = true
object : LoadingTask<Pair<Intent?, Int?>>(this, theme, R.string.loading_msg_export) {
override suspend fun doTask(): Pair<Intent?, Int?> {
return fileShare.share(files, volumeId)
}
}.startTask(lifecycleScope) { (intent, error) ->
if (intent == null) {
onExportFailed(error!!)
} else {
app.isStartingExternalApp = true
startActivity(Intent.createChooser(intent, getString(R.string.share_chooser)))
}
app.isExporting = false
} }
isStartingActivity = true
ExternalProvider.share(this, gocryptfsVolume, paths)
unselectAll() unselectAll()
true true
} }
R.id.decrypt -> { R.id.decrypt -> {
isStartingActivity = true app.isStartingExternalApp = true
pickExportDirectory.launch(null) pickExportDirectory.launch(null)
true true
} }
@ -404,22 +401,85 @@ class ExplorerActivity : BaseExplorerActivity() {
} }
} }
private fun mapFileForMove(items: ArrayList<OperationFile>, srcDirectoryPath: String): ArrayList<OperationFile> { /**
val newItems = ArrayList<OperationFile>() * Ask the user what to do if an item would overwrite another item in case of a move.
items.forEach { *
if (it.explorerElement.isDirectory){ * All [OperationFile] must have a non-null [dstPath][OperationFile.dstPath].
if (gocryptfsVolume.pathExists(PathUtils.pathJoin(currentDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, it.explorerElement.fullPath)))){ */
newItems.addAll( private fun checkMoveOverwrite(items: List<OperationFile>, callback: (List<OperationFile>?) -> Unit) {
mapFileForMove( for (item in items) {
gocryptfsVolume.listDir(it.explorerElement.fullPath).map { e -> OperationFile.fromExplorerElement(e) } as ArrayList<OperationFile>, if (encryptedVolume.pathExists(item.dstPath!!) && !item.overwriteConfirmed) {
srcDirectoryPath CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.warning)
.setMessage(
getString(
if (item.isDirectory) {
R.string.dir_overwrite_question
} else {
R.string.file_overwrite_question
},
item.dstPath!!
) )
) )
.setPositiveButton(R.string.yes) {_, _ ->
item.overwriteConfirmed = true
checkMoveOverwrite(items, callback)
}
.setNegativeButton(R.string.no) { _, _ ->
with(EditTextDialog(this, R.string.enter_new_name) {
item.dstPath = PathUtils.pathJoin(PathUtils.getParentPath(item.dstPath!!), it)
checkMoveOverwrite(items, callback)
}) {
setSelectedText(item.name)
show()
}
}
.show()
return
}
}
callback(items)
}
/**
* Check for destination overwriting in case of a move operation.
*
* If the user decides to merge the content of a folder, the function recursively tests all
* children of the source folder to see if they will overwrite.
*
* The items to be moved are stored in [toMove]. We also need to keep track of the merged
* folders to delete them after the move. These folders are stored in [toClean].
*/
private fun prepareFilesForMove(
items: List<OperationFile>,
toMove: ArrayList<OperationFile>,
toClean: ArrayList<String>,
onReady: () -> Unit
) {
checkMoveOverwrite(items) { checkedItems ->
checkedItems?.let {
for (item in checkedItems) {
if (!item.overwriteConfirmed || !item.isDirectory) {
toMove.add(item)
}
}
val toCheck = mutableListOf<OperationFile>()
for (item in checkedItems) {
if (item.overwriteConfirmed && item.isDirectory) {
val children = encryptedVolume.readDir(item.srcPath)
children?.map {
OperationFile(it.fullPath, it.stat.type, PathUtils.pathJoin(item.dstPath!!, it.name))
}?.let { toCheck.addAll(it) }
toClean.add(item.srcPath)
}
}
if (toCheck.isEmpty()) {
onReady()
} else {
prepareFilesForMove(toCheck, toMove, toClean, onReady)
} }
} }
} }
items.addAll(newItems)
return items
} }
private fun cancelItemAction() { private fun cancelItemAction() {
@ -428,39 +488,4 @@ class ExplorerActivity : BaseExplorerActivity() {
itemsToProcess.clear() itemsToProcess.clear()
} }
} }
override fun onBackPressed() {
if (currentItemAction != ItemsActions.NONE) {
cancelItemAction()
invalidateOptionsMenu()
} else {
super.onBackPressed()
}
}
private fun removeSelectedItems() {
var failedItem: String? = null
for (i in explorerAdapter.selectedItems) {
val element = explorerAdapter.getItem(i)
val fullPath = PathUtils.pathJoin(currentDirectoryPath, element.name)
if (element.isDirectory) {
val result = gocryptfsVolume.recursiveRemoveDirectory(fullPath)
result?.let{ failedItem = it }
} else {
if (!gocryptfsVolume.removeFile(fullPath)) {
failedItem = fullPath
}
}
if (failedItem != null) {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
break
}
}
unselectAll()
setCurrentPath(currentDirectoryPath) //refresh
}
} }

View File

@ -2,63 +2,84 @@ package sushi.hardcore.droidfs.explorers
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import com.google.android.material.floatingactionbutton.FloatingActionButton
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.databinding.ActivityExplorerDropBinding import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.nio.CharBuffer
import java.nio.charset.StandardCharsets
class ExplorerActivityDrop : BaseExplorerActivity() { class ExplorerActivityDrop : BaseExplorerActivity() {
private lateinit var binding: ActivityExplorerDropBinding
override fun init() { override fun init() {
binding = ActivityExplorerDropBinding.inflate(layoutInflater) super.init()
setContentView(binding.root) findViewById<FloatingActionButton>(R.id.fab).setOnClickListener {
binding.fab.setOnClickListener {
openDialogCreateFolder() openDialogCreateFolder()
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.explorer_drop, menu) menuInflater.inflate(R.menu.explorer_drop, menu)
handleMenuItems(menu) val result = super.onCreateOptionsMenu(menu)
menu.findItem(R.id.validate).isVisible = explorerAdapter.selectedItems.isEmpty() menu.findItem(R.id.validate).isVisible = explorerAdapter.selectedItems.isEmpty()
return true return result
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
R.id.validate -> { R.id.validate -> {
val extras = intent.extras val extras = intent.extras
val errorMsg: String? = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) { val success = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
when (intent.action) { when (intent.action) {
Intent.ACTION_SEND -> { Intent.ACTION_SEND -> {
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM) val uri = IntentUtils.getParcelableExtra<Uri>(intent, Intent.EXTRA_STREAM)
if (uri == null) { if (uri == null) {
getString(R.string.share_intent_parsing_failed) false
} else { } else {
importFilesFromUris(listOf(uri), ::onImported) importFilesFromUris(listOf(uri), ::onImported)
null true
} }
} }
Intent.ACTION_SEND_MULTIPLE -> { Intent.ACTION_SEND_MULTIPLE -> {
val uris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM) val uris: List<Uri>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("Deprecation")
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
if (uris != null) { if (uris != null) {
importFilesFromUris(uris, ::onImported) importFilesFromUris(uris, ::onImported)
null true
} else { } else {
getString(R.string.share_intent_parsing_failed) false
} }
} }
else -> getString(R.string.share_intent_parsing_failed) else -> false
} }
} else if ((intent.clipData?.itemCount ?: 0) > 0) {
val byteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(intent.clipData!!.getItemAt(0).text))
val byteArray = ByteArray(byteBuffer.remaining())
byteBuffer.get(byteArray)
val size = byteArray.size.toLong()
createNewFile {
var offset = 0L
while (offset < size) {
offset += encryptedVolume.write(it, offset, byteArray, offset, size-offset)
}
encryptedVolume.closeFile(it)
onImported()
}
true
} else { } else {
getString(R.string.share_intent_parsing_failed) false
} }
errorMsg?.let { if (!success) {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(it) .setMessage(R.string.share_intent_parsing_failed)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} }
@ -68,23 +89,15 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
} }
} }
private fun onImported(failedItem: String?){ private fun onImported() {
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath)
if (failedItem == null) { CustomAlertDialogBuilder(this, theme)
ColoredAlertDialogBuilder(this) .setTitle(R.string.success_import)
.setTitle(R.string.success_import) .setMessage(R.string.success_import_msg)
.setMessage(R.string.success_import_msg) .setCancelable(false)
.setCancelable(false) .setPositiveButton(R.string.ok) { _, _ ->
.setPositiveButton(R.string.ok){_, _ -> finish()
finish() }
} .show()
.show()
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
} }
} }

View File

@ -5,39 +5,35 @@ import android.content.Intent
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.GocryptfsVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import java.util.*
class ExplorerActivityPick : BaseExplorerActivity() { class ExplorerActivityPick : BaseExplorerActivity() {
private var resultIntent = Intent() private var resultIntent = Intent()
private var isFinishingIntentionally = false private var isFinishingIntentionally = false
override fun init() { override fun init() {
super.init() setContentView(R.layout.activity_explorer_pick)
resultIntent.putExtra("sessionID", gocryptfsVolume.sessionID) resultIntent.putExtra("volumeId", volumeId)
} }
override fun bindFileOperationService() { override fun bindFileOperationService() {
//don't bind //don't bind
} }
override fun onExplorerItemClick(position: Int) { override fun onExplorerElementClick(position: Int) {
val wasSelecting = explorerAdapter.selectedItems.isNotEmpty()
explorerAdapter.onItemClick(position)
if (explorerAdapter.selectedItems.isEmpty()) { if (explorerAdapter.selectedItems.isEmpty()) {
if (!wasSelecting) { val fullPath = PathUtils.pathJoin(currentDirectoryPath, explorerElements[position].name)
val fullPath = PathUtils.pathJoin(currentDirectoryPath, explorerElements[position].name) when {
when { explorerElements[position].isDirectory -> {
explorerElements[position].isDirectory -> { setCurrentPath(fullPath)
setCurrentPath(fullPath) }
} explorerElements[position].isParentFolder -> {
explorerElements[position].isParentFolder -> { setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath)) }
} else -> {
else -> { resultIntent.putExtra("path", fullPath)
resultIntent.putExtra("path", fullPath) returnActivityResult()
returnActivityResult()
}
} }
} }
} }
@ -46,11 +42,11 @@ class ExplorerActivityPick : BaseExplorerActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.explorer_pick, menu) menuInflater.inflate(R.menu.explorer_pick, menu)
handleMenuItems(menu) val result = super.onCreateOptionsMenu(menu)
val anyItemSelected = explorerAdapter.selectedItems.isNotEmpty() val anyItemSelected = explorerAdapter.selectedItems.isNotEmpty()
menu.findItem(R.id.select_all).isVisible = anyItemSelected menu.findItem(R.id.select_all).isVisible = anyItemSelected
menu.findItem(R.id.validate).isVisible = anyItemSelected menu.findItem(R.id.validate).isVisible = anyItemSelected
return true return result
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -66,7 +62,7 @@ class ExplorerActivityPick : BaseExplorerActivity() {
for (i in explorerAdapter.selectedItems) { for (i in explorerAdapter.selectedItems) {
val e = explorerElements[i] val e = explorerElements[i]
paths.add(PathUtils.pathJoin(currentDirectoryPath, e.name)) paths.add(PathUtils.pathJoin(currentDirectoryPath, e.name))
types.add(e.elementType.toInt()) types.add(e.stat.type)
} }
resultIntent.putStringArrayListExtra("paths", paths) resultIntent.putStringArrayListExtra("paths", paths)
resultIntent.putIntegerArrayListExtra("types", types) resultIntent.putIntegerArrayListExtra("types", types)
@ -82,21 +78,4 @@ class ExplorerActivityPick : BaseExplorerActivity() {
isFinishingIntentionally = true isFinishingIntentionally = true
finish() finish()
} }
override fun closeVolumeOnDestroy() {
if (!isFinishingIntentionally && !usf_keep_open){
val sessionID = intent.getIntExtra("originalSessionID", -1)
if (sessionID != -1){
val v = GocryptfsVolume(sessionID)
v.close()
}
super.closeVolumeOnDestroy()
}
}
override fun closeVolumeOnUserExit() {
isFinishingIntentionally = true
super.closeVolumeOnUserExit()
super.closeVolumeOnDestroy()
}
} }

View File

@ -1,26 +1,31 @@
package sushi.hardcore.droidfs.explorers package sushi.hardcore.droidfs.explorers
import sushi.hardcore.droidfs.collation.getCollationKeyForFileName
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import java.util.* import java.text.Collator
class ExplorerElement(val name: String, val elementType: Short, var size: Long = -1, mTime: Long = -1, val parentPath: String) { class ExplorerElement(val name: String, val stat: Stat, val parentPath: String) {
val mTime = Date((mTime * 1000).toString().toLong()) val fullPath: String = PathUtils.pathJoin(parentPath.ifEmpty { "/" }, name)
val fullPath: String = PathUtils.pathJoin(parentPath, name) val collationKey = Collator.getInstance().getCollationKeyForFileName(fullPath)
val isDirectory: Boolean val isDirectory: Boolean
get() = elementType.toInt() == 0 get() = stat.type == Stat.S_IFDIR
val isParentFolder: Boolean
get() = elementType.toInt() == -1
val isRegularFile: Boolean val isRegularFile: Boolean
get() = elementType.toInt() == 1 get() = stat.type == Stat.S_IFREG
val isSymlink: Boolean
get() = stat.type == Stat.S_IFLNK
val isParentFolder: Boolean
get() = stat.type == Stat.PARENT_FOLDER_TYPE
companion object { companion object {
@JvmStatic @JvmStatic
//this function is needed because I had some problems calling the constructor from JNI, probably due to arguments with default values //this function is needed because I had some problems calling the constructor from JNI, probably due to arguments with default values
fun new(name: String, elementType: Short, size: Long, mTime: Long, parentPath: String): ExplorerElement { fun new(name: String, elementType: Int, size: Long, mTime: Long, parentPath: String): ExplorerElement {
return ExplorerElement(name, elementType, size, mTime, parentPath) return ExplorerElement(name, Stat(elementType, size, mTime*1000), parentPath)
} }
private fun foldersFirst(a: ExplorerElement, b: ExplorerElement, default: () -> Int): Int { private fun foldersFirst(a: ExplorerElement, b: ExplorerElement, default: () -> Int): Int {
@ -33,42 +38,48 @@ class ExplorerElement(val name: String, val elementType: Short, var size: Long =
} }
} }
private fun doSort(a: ExplorerElement, b: ExplorerElement, foldersFirst: Boolean, sorter: () -> Int): Int { private fun doSort(a: ExplorerElement, b: ExplorerElement, foldersFirst: Boolean, sorter: () -> Int): Int {
return if (foldersFirst) { return if (b.isParentFolder) {
foldersFirst(a, b, sorter) 1
} else if (a.isParentFolder) {
-1
} else { } else {
sorter() if (foldersFirst) {
foldersFirst(a, b, sorter)
} else {
sorter()
}
} }
} }
fun sortBy(sortOrder: String, foldersFirst: Boolean, explorerElements: MutableList<ExplorerElement>) { fun sortBy(sortOrder: String, foldersFirst: Boolean, explorerElements: MutableList<ExplorerElement>) {
when (sortOrder) { when (sortOrder) {
"name" -> { "name" -> {
explorerElements.sortWith { a, b -> explorerElements.sortWith { a, b ->
doSort(a, b, foldersFirst) { a.fullPath.compareTo(b.fullPath, true) } doSort(a, b, foldersFirst) { a.collationKey.compareTo(b.collationKey) }
} }
} }
"size" -> { "size" -> {
explorerElements.sortWith { a, b -> explorerElements.sortWith { a, b ->
doSort(a, b, foldersFirst) { (a.size - b.size).toInt() } doSort(a, b, foldersFirst) { (a.stat.size - b.stat.size).toInt() }
} }
} }
"date" -> { "date" -> {
explorerElements.sortWith { a, b -> explorerElements.sortWith { a, b ->
doSort(a, b, foldersFirst) { a.mTime.compareTo(b.mTime) } doSort(a, b, foldersFirst) { a.stat.mTime.compareTo(b.stat.mTime) }
} }
} }
"name_desc" -> { "name_desc" -> {
explorerElements.sortWith { a, b -> explorerElements.sortWith { a, b ->
doSort(a, b, foldersFirst) { b.fullPath.compareTo(a.fullPath, true) } doSort(a, b, foldersFirst) { b.collationKey.compareTo(a.collationKey) }
} }
} }
"size_desc" -> { "size_desc" -> {
explorerElements.sortWith { a, b -> explorerElements.sortWith { a, b ->
doSort(a, b, foldersFirst) { (b.size - a.size).toInt() } doSort(a, b, foldersFirst) { (b.stat.size - a.stat.size).toInt() }
} }
} }
"date_desc" -> { "date_desc" -> {
explorerElements.sortWith { a, b -> explorerElements.sortWith { a, b ->
doSort(a, b, foldersFirst) { b.mTime.compareTo(a.mTime) } doSort(a, b, foldersFirst) { b.stat.mTime.compareTo(a.stat.mTime) }
} }
} }
} }

View File

@ -0,0 +1,27 @@
package sushi.hardcore.droidfs.explorers
import android.content.Context
import android.content.Intent
import sushi.hardcore.droidfs.util.IntentUtils
class ExplorerRouter(private val context: Context, private val intent: Intent) {
var pickMode = intent.action == "pick"
var dropMode = (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) && intent.extras != null
fun getExplorerIntent(volumeId: Int, volumeShortName: String): Intent {
var explorerIntent: Intent? = null
if (dropMode) { //import via android share menu
explorerIntent = Intent(context, ExplorerActivityDrop::class.java)
IntentUtils.forwardIntent(intent, explorerIntent)
} else if (pickMode) {
explorerIntent = Intent(context, ExplorerActivityPick::class.java)
explorerIntent.flags = Intent.FLAG_ACTIVITY_FORWARD_RESULT
}
if (explorerIntent == null) {
explorerIntent = Intent(context, ExplorerActivity::class.java) //default opening
}
explorerIntent.putExtra("volumeId", volumeId)
explorerIntent.putExtra("volumeName", volumeShortName)
return explorerIntent
}
}

View File

@ -1,5 +0,0 @@
package sushi.hardcore.droidfs.file_operations
import android.app.Notification
class FileOperationNotification(val notificationBuilder: Notification.Builder, val notificationId: Int)

View File

@ -1,324 +1,562 @@
package sushi.hardcore.droidfs.file_operations package sushi.hardcore.droidfs.file_operations
import android.app.* import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.drawable.Icon import android.content.ServiceConnection
import android.content.pm.ServiceInfo
import android.net.Uri import android.net.Uri
import android.os.* import android.os.Binder
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import sushi.hardcore.droidfs.GocryptfsVolume import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.NotificationBroadcastReceiver
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManager
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.AndroidUtils
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.Wiper import sushi.hardcore.droidfs.util.Wiper
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Foreground service for file operations.
*
* Clients **must** bind to it using the [bind] method.
*
* This implementation is not thread-safe. It must only be called from the main UI thread.
*/
class FileOperationService : Service() { class FileOperationService : Service() {
companion object {
const val NOTIFICATION_CHANNEL_ID = "FileOperations"
const val ACTION_CANCEL = "file_operation_cancel"
}
private val binder = LocalBinder()
private lateinit var gocryptfsVolume: GocryptfsVolume
private lateinit var notificationManager: NotificationManager
private var notifications = HashMap<Int, Boolean>()
private var lastNotificationId = 0
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
fun getService(): FileOperationService = this@FileOperationService fun getService(): FileOperationService = this@FileOperationService
fun setGocryptfsVolume(g: GocryptfsVolume) { }
gocryptfsVolume = g
inner class PendingTask<T>(
val title: Int,
val total: Int?,
private val getTask: (Int) -> Deferred<T>,
private val onStart: (taskId: Int, job: Deferred<T>) -> Unit,
) {
fun start(taskId: Int): Deferred<T> = getTask(taskId).also { onStart(taskId, it) }
}
companion object {
const val TAG = "FileOperationService"
const val NOTIFICATION_CHANNEL_ID = "FileOperations"
const val ACTION_CANCEL = "file_operation_cancel"
/**
* Bind to the service.
*
* Registers an [ActivityResultLauncher] in the provided activity to request notification permission. Consequently, the activity must not yet be started.
*
* The activity must stay running while calling the service's methods.
*
* If multiple activities bind simultaneously, only the latest one will be used by the service.
*/
fun bind(activity: BaseActivity, onBound: (FileOperationService) -> Unit) {
val helper = AndroidUtils.NotificationPermissionHelper(activity)
lateinit var service: FileOperationService
val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, binder: IBinder) {
onBound((binder as FileOperationService.LocalBinder).getService().also {
service = it
it.notificationPermissionHelpers.addLast(helper)
})
}
override fun onServiceDisconnected(arg0: ComponentName) {}
}
activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
activity.unbindService(serviceConnection)
service.notificationPermissionHelpers.removeLast()
}
})
activity.bindService(
Intent(activity, FileOperationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
} }
} }
override fun onBind(p0: Intent?): IBinder { private var isStarted = false
return binder private val binder = LocalBinder()
private lateinit var volumeManger: VolumeManager
private var serviceScope = MainScope()
private val notificationPermissionHelpers = ArrayDeque<AndroidUtils.NotificationPermissionHelper<BaseActivity>>(2)
private var askForNotificationPermission = true
private lateinit var notificationManager: NotificationManagerCompat
private val notifications = HashMap<Int, NotificationCompat.Builder>()
private var foregroundNotificationId = -1
private val tasks = HashMap<Int, Job>()
private var newTaskId = 1
private var pendingTask: PendingTask<*>? = null
override fun onCreate() {
volumeManger = (application as VolumeManagerApp).volumeManager
} }
private fun showNotification(message: Int, total: Int?): FileOperationNotification { override fun onBind(p0: Intent?): IBinder = binder
++lastNotificationId
if (!::notificationManager.isInitialized){ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager startPendingTask { id, notification ->
// on service start, the pending task is the foreground task
setForeground(id, notification)
} }
val notificationBuilder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { isStarted = true
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, getString(R.string.file_operations), NotificationManager.IMPORTANCE_LOW) return START_NOT_STICKY
notificationManager.createNotificationChannel(channel) }
Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
override fun onDestroy() {
isStarted = false
}
private fun processPendingTask() {
if (isStarted) {
startPendingTask { id, notification ->
if (foregroundNotificationId == -1) {
// service started but not in foreground yet
setForeground(id, notification)
} else {
// already running in foreground, just add a new notification
notificationManager.notify(id, notification)
}
}
} else { } else {
Notification.Builder(this) ContextCompat.startForegroundService(
} this,
val cancelIntent = Intent(this, NotificationBroadcastReceiver::class.java).apply { Intent(this, FileOperationService::class.java)
val bundle = Bundle()
bundle.putBinder("binder", LocalBinder())
bundle.putInt("notificationId", lastNotificationId)
putExtra("bundle", bundle)
action = ACTION_CANCEL
}
val cancelPendingIntent = PendingIntent.getBroadcast(this, 0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT)
val notificationAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Notification.Action.Builder(
Icon.createWithResource(this, R.drawable.icon_close),
getString(R.string.cancel),
cancelPendingIntent
)
} else {
Notification.Action.Builder(
R.drawable.icon_close,
getString(R.string.cancel),
cancelPendingIntent
) )
} }
notificationBuilder }
.setContentTitle(getString(message))
.setSmallIcon(R.mipmap.icon_launcher) /**
.setOngoing(true) * Start the pending task and create an associated notification.
.addAction(notificationAction.build()) */
if (total != null) { private fun startPendingTask(showNotification: (id: Int, Notification) -> Unit) {
val task = pendingTask
pendingTask = null
if (task == null) {
Log.w(TAG, "Started without pending task")
return
}
if (!::notificationManager.isInitialized) {
notificationManager = NotificationManagerCompat.from(this)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
getString(R.string.file_operations),
NotificationManager.IMPORTANCE_LOW
)
)
}
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(getString(task.title))
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.addAction(NotificationCompat.Action(
R.drawable.icon_close,
getString(R.string.cancel),
PendingIntent.getBroadcast(
this,
newTaskId,
Intent(this, NotificationBroadcastReceiver::class.java).apply {
putExtra("bundle", Bundle().apply {
putBinder("binder", LocalBinder())
putInt("taskId", newTaskId)
})
action = ACTION_CANCEL
},
PendingIntent.FLAG_IMMUTABLE
)
))
if (task.total != null) {
notificationBuilder notificationBuilder
.setContentText("0/$total") .setContentText("0/${task.total}")
.setProgress(total, 0, false) .setProgress(task.total, 0, false)
} else { } else {
notificationBuilder notificationBuilder
.setContentText(getString(R.string.discovering_files)) .setContentText(getString(R.string.discovering_files))
.setProgress(0, 0, true) .setProgress(0, 0, true)
} }
notifications[lastNotificationId] = false showNotification(newTaskId, notificationBuilder.build())
notificationManager.notify(lastNotificationId, notificationBuilder.build()) notifications[newTaskId] = notificationBuilder
return FileOperationNotification(notificationBuilder, lastNotificationId) tasks[newTaskId] = task.start(newTaskId)
newTaskId++
} }
private fun updateNotificationProgress(notification: FileOperationNotification, progress: Int, total: Int){ private fun setForeground(id: Int, notification: Notification) {
notification.notificationBuilder ServiceCompat.startForeground(this, id, notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
}
)
foregroundNotificationId = id
}
private fun updateNotificationProgress(taskId: Int, progress: Int, total: Int) {
val notificationBuilder = notifications[taskId] ?: return
notificationBuilder
.setProgress(total, progress, false) .setProgress(total, progress, false)
.setContentText("$progress/$total") .setContentText("$progress/$total")
notificationManager.notify(notification.notificationId, notification.notificationBuilder.build()) notificationManager.notify(taskId, notificationBuilder.build())
} }
private fun cancelNotification(notification: FileOperationNotification){ fun cancelOperation(taskId: Int) {
notificationManager.cancel(notification.notificationId) tasks[taskId]?.cancel()
} }
fun cancelOperation(notificationId: Int){ private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
notifications[notificationId] = true return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
} }
private fun copyFile(srcPath: String, dstPath: String, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume): Boolean { /**
* Wait on a task, returning the appropriate [TaskResult].
*
* This method also performs cleanup and foreground state management so it must be always used.
*/
private suspend fun <T> waitForTask(
taskId: Int,
task: Deferred<T>,
onCancelled: (suspend () -> Unit)?,
): TaskResult<out T> {
return coroutineScope {
withContext(serviceScope.coroutineContext) {
try {
TaskResult.completed(task.await())
} catch (e: CancellationException) {
onCancelled?.invoke()
TaskResult.cancelled()
} catch (e: Throwable) {
e.printStackTrace()
TaskResult.error(e.localizedMessage)
} finally {
notificationManager.cancel(taskId)
notifications.remove(taskId)
tasks.remove(taskId)
if (tasks.size == 0) {
// last task finished, remove from foreground state but don't stop the service
ServiceCompat.stopForeground(this@FileOperationService, ServiceCompat.STOP_FOREGROUND_REMOVE)
foregroundNotificationId = -1
} else if (taskId == foregroundNotificationId) {
// foreground task finished, falling back to the next one
val entry = notifications.entries.first()
setForeground(entry.key, entry.value.build())
}
}
}
}
}
/**
* Create and run a new task until completion.
*
* Handles notification permission request, service startup and notification management.
*
* Overrides [pendingTask] without checking! (safe if user is not insanely fast)
*/
private suspend fun <T> newTask(
title: Int,
total: Int?,
getTask: (taskId: Int) -> Deferred<T>,
onCancelled: (suspend () -> Unit)?,
): TaskResult<out T> {
val startedTask = suspendCoroutine { continuation ->
val task = PendingTask(title, total, getTask) { taskId, job ->
continuation.resume(Pair(taskId, job))
}
pendingTask = task
if (askForNotificationPermission) {
with (notificationPermissionHelpers.last()) {
askAndRun { granted ->
if (granted) {
processPendingTask()
} else {
CustomAlertDialogBuilder(activity, activity.theme)
.setTitle(R.string.warning)
.setMessage(R.string.notification_denied_msg)
.setPositiveButton(R.string.settings) { _, _ ->
(application as VolumeManagerApp).isStartingExternalApp = true
activity.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)
)
)
}
.setNegativeButton(R.string.later, null)
.setOnDismissListener { processPendingTask() }
.show()
}
}
}
askForNotificationPermission = false // only ask once per service instance
return@suspendCoroutine
}
processPendingTask()
}
return waitForTask(startedTask.first, startedTask.second, onCancelled)
}
private suspend fun <T> volumeTask(
title: Int,
total: Int?,
volumeId: Int,
task: suspend (taskId: Int, encryptedVolume: EncryptedVolume) -> T
): TaskResult<out T> {
return newTask(title, total, { taskId ->
volumeManger.getCoroutineScope(volumeId).async {
task(taskId, getEncryptedVolume(volumeId))
}
}, null)
}
private suspend fun <T> globalTask(
title: Int,
total: Int?,
task: suspend (taskId: Int) -> T,
onCancelled: (suspend () -> Unit)? = null,
): TaskResult<out T> {
return newTask(title, total, { taskId ->
serviceScope.async(Dispatchers.IO) {
task(taskId)
}
}, if (onCancelled == null) {
null
} else {
{
serviceScope.launch(Dispatchers.IO) {
onCancelled()
}
}
})
}
private suspend fun copyFile(
encryptedVolume: EncryptedVolume,
srcPath: String,
dstPath: String,
srcEncryptedVolume: EncryptedVolume = encryptedVolume,
): Boolean {
var success = true var success = true
val srcHandleId = remoteGocryptfsVolume.openReadMode(srcPath) val srcFileHandle = srcEncryptedVolume.openFileReadMode(srcPath)
if (srcHandleId != -1){ if (srcFileHandle != -1L) {
val dstHandleId = gocryptfsVolume.openWriteMode(dstPath) val dstFileHandle = encryptedVolume.openFileWriteMode(dstPath)
if (dstHandleId != -1){ if (dstFileHandle != -1L) {
var offset: Long = 0 var offset: Long = 0
val ioBuffer = ByteArray(GocryptfsVolume.DefaultBS) val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
var length: Int var length: Long
while (remoteGocryptfsVolume.readFile(srcHandleId, offset, ioBuffer).also { length = it } > 0) { while (srcEncryptedVolume.read(srcFileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it.toLong() } > 0) {
val written = gocryptfsVolume.writeFile(dstHandleId, offset, ioBuffer, length).toLong() yield()
if (written == length.toLong()) { val written = encryptedVolume.write(dstFileHandle, offset, ioBuffer, 0, length).toLong()
if (written == length) {
offset += written offset += written
} else { } else {
success = false success = false
break break
} }
} }
gocryptfsVolume.closeFile(dstHandleId) encryptedVolume.truncate(dstPath, offset)
encryptedVolume.closeFile(dstFileHandle)
} else { } else {
success = false success = false
} }
remoteGocryptfsVolume.closeFile(srcHandleId) srcEncryptedVolume.closeFile(srcFileHandle)
} else { } else {
success = false success = false
} }
return success return success
} }
fun copyElements(items: ArrayList<OperationFile>, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume, callback: (String?) -> Unit){ suspend fun copyElements(
Thread { volumeId: Int,
val notification = showNotification(R.string.file_op_copy_msg, items.size) items: List<OperationFile>,
srcVolumeId: Int = volumeId,
): TaskResult<out String?> {
val srcEncryptedVolume = getEncryptedVolume(srcVolumeId)
return volumeTask(R.string.file_op_copy_msg, items.size, volumeId) { taskId, encryptedVolume ->
var failedItem: String? = null var failedItem: String? = null
for (i in 0 until items.size){ for (i in items.indices) {
if (notifications[notification.notificationId]!!){ yield()
cancelNotification(notification) if (items[i].isDirectory) {
return@Thread if (!encryptedVolume.pathExists(items[i].dstPath!!)) {
} if (!encryptedVolume.mkdir(items[i].dstPath!!)) {
if (items[i].explorerElement.isDirectory){ failedItem = items[i].srcPath
if (!gocryptfsVolume.pathExists(items[i].dstPath!!)) {
if (!gocryptfsVolume.mkdir(items[i].dstPath!!)) {
failedItem = items[i].explorerElement.fullPath
} }
} }
} else { } else if (!copyFile(encryptedVolume, items[i].srcPath, items[i].dstPath!!, srcEncryptedVolume)) {
if (!copyFile(items[i].explorerElement.fullPath, items[i].dstPath!!, remoteGocryptfsVolume)){ failedItem = items[i].srcPath
failedItem = items[i].explorerElement.fullPath
}
} }
if (failedItem == null){ if (failedItem == null) {
updateNotificationProgress(notification, i, items.size) updateNotificationProgress(taskId, i+1, items.size)
} else { } else {
break break
} }
} }
cancelNotification(notification) failedItem
callback(failedItem) }
}.start()
} }
fun moveElements(items: ArrayList<OperationFile>, callback: (String?) -> Unit){ suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
Thread { return volumeTask(R.string.file_op_move_msg, toMove.size, volumeId) { taskId, encryptedVolume ->
val notification = showNotification(R.string.file_op_move_msg, items.size) val total = toMove.size+toClean.size
val mergedFolders = ArrayList<String>()
var failedItem: String? = null var failedItem: String? = null
for (i in 0 until items.size){ for ((i, item) in toMove.withIndex()) {
if (notifications[notification.notificationId]!!){ if (!encryptedVolume.rename(item.srcPath, item.dstPath!!)) {
cancelNotification(notification) failedItem = item.srcPath
return@Thread break
}
if (items[i].explorerElement.isDirectory && gocryptfsVolume.pathExists(items[i].dstPath!!)){ //folder will be merged
mergedFolders.add(items[i].explorerElement.fullPath)
} else { } else {
if (!gocryptfsVolume.rename(items[i].explorerElement.fullPath, items[i].dstPath!!)){ updateNotificationProgress(taskId, i+1, total)
failedItem = items[i].explorerElement.fullPath }
}
if (failedItem == null) {
for ((i, folderPath) in toClean.asReversed().withIndex()) {
if (!encryptedVolume.rmdir(folderPath)) {
failedItem = folderPath
break break
} else { } else {
updateNotificationProgress(notification, i, items.size) updateNotificationProgress(taskId, toMove.size+i+1, total)
} }
} }
} }
if (failedItem == null){ failedItem
for (i in 0 until mergedFolders.size) { }
if (notifications[notification.notificationId]!!){
cancelNotification(notification)
return@Thread
}
if (!gocryptfsVolume.rmdir(mergedFolders[i])){
failedItem = mergedFolders[i]
break
} else {
updateNotificationProgress(notification, items.size-(mergedFolders.size-i), items.size)
}
}
}
cancelNotification(notification)
callback(failedItem)
}.start()
} }
private fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>, reuseNotification: FileOperationNotification? = null, callback: (String?) -> Unit){ private suspend fun importFilesFromUris(
val notification = reuseNotification ?: showNotification(R.string.file_op_import_msg, dstPaths.size) encryptedVolume: EncryptedVolume,
dstPaths: List<String>,
uris: List<Uri>,
taskId: Int,
): String? {
var failedIndex = -1 var failedIndex = -1
for (i in dstPaths.indices) { for (i in dstPaths.indices) {
if (notifications[notification.notificationId]!!){ yield()
cancelNotification(notification)
return
}
try { try {
if (!gocryptfsVolume.importFile(this, uris[i], dstPaths[i])) { if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
failedIndex = i failedIndex = i
} }
} catch (e: FileNotFoundException){ } catch (e: FileNotFoundException) {
failedIndex = i failedIndex = i
} }
if (failedIndex == -1) { if (failedIndex == -1) {
updateNotificationProgress(notification, i, dstPaths.size) updateNotificationProgress(taskId, i+1, dstPaths.size)
} else { } else {
cancelNotification(notification) return uris[failedIndex].toString()
callback(uris[failedIndex].toString())
break
} }
} }
if (failedIndex == -1){ return null
cancelNotification(notification)
callback(null)
}
} }
fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>, callback: (String?) -> Unit) { suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
Thread { return volumeTask(R.string.file_op_import_msg, dstPaths.size, volumeId) { taskId, encryptedVolume ->
importFilesFromUris(dstPaths, uris, null, callback) importFilesFromUris(encryptedVolume, dstPaths, uris, taskId)
}.start() }
} }
/** /**
* Map the content of an unencrypted directory to prepare its import * Map the content of an unencrypted directory to prepare its import
* *
* Contents of dstFiles and srcUris, at the same index, will match each other * Contents of dstFiles and srcUris, at the same index, will match each other
*
* @return false if cancelled early, true otherwise.
*/ */
private fun recursiveMapDirectoryForImport( private suspend fun recursiveMapDirectoryForImport(
rootSrcDir: DocumentFile, rootSrcDir: DocumentFile,
rootDstPath: String, rootDstPath: String,
dstFiles: ArrayList<String>, dstFiles: ArrayList<String>,
srcUris: ArrayList<Uri>, srcUris: ArrayList<Uri>,
dstDirs: ArrayList<String>, dstDirs: ArrayList<String>,
notification: FileOperationNotification ) {
): Boolean {
dstDirs.add(rootDstPath) dstDirs.add(rootDstPath)
for (child in rootSrcDir.listFiles()) { for (child in rootSrcDir.listFiles()) {
if (notifications[notification.notificationId]!!) { yield()
cancelNotification(notification)
return false
}
child.name?.let { name -> child.name?.let { name ->
val subPath = PathUtils.pathJoin(rootDstPath, name) val subPath = PathUtils.pathJoin(rootDstPath, name)
if (child.isDirectory) { if (child.isDirectory) {
if (!recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs, notification)) { recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs)
return false } else if (child.isFile) {
}
}
else if (child.isFile) {
srcUris.add(child.uri) srcUris.add(child.uri)
dstFiles.add(subPath) dstFiles.add(subPath)
} }
} }
} }
return true
} }
fun importDirectory(rootDstPath: String, rootSrcDir: DocumentFile, callback: (String?, List<Uri>) -> Unit) { class ImportDirectoryResult(val taskResult: TaskResult<out String?>, val uris: List<Uri>)
Thread {
val notification = showNotification(R.string.file_op_import_msg, null)
suspend fun importDirectory(
volumeId: Int,
rootDstPath: String,
rootSrcDir: DocumentFile,
): ImportDirectoryResult {
val srcUris = arrayListOf<Uri>()
return ImportDirectoryResult(volumeTask(R.string.file_op_import_msg, null, volumeId) { taskId, encryptedVolume ->
var failedItem: String? = null
val dstFiles = arrayListOf<String>() val dstFiles = arrayListOf<String>()
val srcUris = arrayListOf<Uri>()
val dstDirs = arrayListOf<String>() val dstDirs = arrayListOf<String>()
if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, notification)) { recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs)
return@Thread
}
// create destination folders so the new files can use them // create destination folders so the new files can use them
for (dir in dstDirs) { for (dir in dstDirs) {
if (notifications[notification.notificationId]!!) { // if directory creation fails, check if it was already present
cancelNotification(notification) if (!encryptedVolume.mkdir(dir) && encryptedVolume.getAttr(dir)?.type != Stat.S_IFDIR) {
return@Thread failedItem = dir
}
if (!gocryptfsVolume.mkdir(dir)) {
cancelNotification(notification)
callback(dir, srcUris)
break break
} }
} }
if (failedItem == null) {
importFilesFromUris(dstFiles, srcUris, notification) { failedItem -> failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, taskId)
callback(failedItem, srcUris)
} }
}.start() failedItem
}, srcUris)
} }
fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null, callback: (String?) -> Unit){ suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
Thread { return globalTask(R.string.file_op_wiping_msg, uris.size, { taskId ->
val notification = showNotification(R.string.file_op_wiping_msg, uris.size)
var errorMsg: String? = null var errorMsg: String? = null
for (i in uris.indices) { for (i in uris.indices) {
if (notifications[notification.notificationId]!!){ yield()
cancelNotification(notification) errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
return@Thread
}
errorMsg = Wiper.wipe(this, uris[i])
if (errorMsg == null) { if (errorMsg == null) {
updateNotificationProgress(notification, i, uris.size) updateNotificationProgress(taskId, i+1, uris.size)
} else { } else {
break break
} }
@ -326,34 +564,35 @@ class FileOperationService : Service() {
if (errorMsg == null) { if (errorMsg == null) {
rootFile?.delete() rootFile?.delete()
} }
cancelNotification(notification) errorMsg
callback(errorMsg) })
}.start()
} }
private fun exportFileInto(srcPath: String, treeDocumentFile: DocumentFile): Boolean { private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
val outputStream = treeDocumentFile.createFile("*/*", File(srcPath).name)?.uri?.let { val outputStream = treeDocumentFile.createFile("*/*", File(srcPath).name)?.uri?.let {
contentResolver.openOutputStream(it) contentResolver.openOutputStream(it)
} }
return if (outputStream == null) { return if (outputStream == null) {
false false
} else { } else {
gocryptfsVolume.exportFile(srcPath, outputStream) encryptedVolume.exportFile(srcPath, outputStream)
} }
} }
private fun recursiveExportDirectory(plain_directory_path: String, treeDocumentFile: DocumentFile): String? { private suspend fun recursiveExportDirectory(
encryptedVolume: EncryptedVolume,
plain_directory_path: String,
treeDocumentFile: DocumentFile,
): String? {
treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree -> treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree ->
val explorerElements = gocryptfsVolume.listDir(plain_directory_path) val explorerElements = encryptedVolume.readDir(plain_directory_path) ?: return null
for (e in explorerElements) { for (e in explorerElements) {
yield()
val fullPath = PathUtils.pathJoin(plain_directory_path, e.name) val fullPath = PathUtils.pathJoin(plain_directory_path, e.name)
if (e.isDirectory) { if (e.isDirectory) {
val failedItem = recursiveExportDirectory(fullPath, childTree) recursiveExportDirectory(encryptedVolume, fullPath, childTree)?.let { return it }
failedItem?.let { return it } } else if (!exportFileInto(encryptedVolume, fullPath, childTree)) {
} else { return fullPath
if (!exportFileInto(fullPath, childTree)){
return fullPath
}
} }
} }
return null return null
@ -361,31 +600,123 @@ class FileOperationService : Service() {
return treeDocumentFile.name return treeDocumentFile.name
} }
fun exportFiles(uri: Uri, items: List<ExplorerElement>, callback: (String?) -> Unit){ suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
Thread { return volumeTask(R.string.file_op_export_msg, items.size, volumeId) { taskId, encryptedVolume ->
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
DocumentFile.fromTreeUri(this, uri)?.let { treeDocumentFile -> var failedItem: String? = null
val notification = showNotification(R.string.file_op_export_msg, items.size) for (i in items.indices) {
var failedItem: String? = null yield()
for (i in items.indices) { failedItem = if (items[i].isDirectory) {
if (notifications[notification.notificationId]!!){ recursiveExportDirectory(encryptedVolume, items[i].fullPath, treeDocumentFile)
cancelNotification(notification) } else {
return@Thread if (exportFileInto(encryptedVolume, items[i].fullPath, treeDocumentFile)) {
} null
failedItem = if (items[i].isDirectory) {
recursiveExportDirectory(items[i].fullPath, treeDocumentFile)
} else { } else {
if (exportFileInto(items[i].fullPath, treeDocumentFile)) null else items[i].fullPath items[i].fullPath
}
if (failedItem == null) {
updateNotificationProgress(notification, i, items.size)
} else {
break
} }
} }
cancelNotification(notification) if (failedItem == null) {
callback(failedItem) updateNotificationProgress(taskId, i+1, items.size)
} else {
break
}
} }
}.start() failedItem
}
}
private suspend fun recursiveRemoveDirectory(encryptedVolume: EncryptedVolume, path: String): String? {
encryptedVolume.readDir(path)?.let { elements ->
for (e in elements) {
yield()
val fullPath = PathUtils.pathJoin(path, e.name)
if (e.isDirectory) {
recursiveRemoveDirectory(encryptedVolume, fullPath)?.let { return it }
} else if (!encryptedVolume.deleteFile(fullPath)) {
return fullPath
}
}
}
return if (!encryptedVolume.rmdir(path)) {
path
} else {
null
}
}
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
return volumeTask(R.string.file_op_delete_msg, items.size, volumeId) { taskId, encryptedVolume ->
var failedItem: String? = null
for ((i, element) in items.withIndex()) {
yield()
if (element.isDirectory) {
recursiveRemoveDirectory(encryptedVolume, element.fullPath)?.let { failedItem = it }
} else if (!encryptedVolume.deleteFile(element.fullPath)) {
failedItem = element.fullPath
}
if (failedItem == null) {
updateNotificationProgress(taskId, i + 1, items.size)
} else {
break
}
}
failedItem
}.failedItem // treat cancellation as success
}
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile): Int {
yield()
val children = rootDirectory.listFiles()
var count = children.size
for (child in children) {
if (child.isDirectory) {
count += recursiveCountChildElements(child)
}
}
return count
}
private suspend fun recursiveCopyVolume(
src: DocumentFile,
dst: DocumentFile,
dstRootDirectory: ObjRef<DocumentFile?>?,
taskId: Int,
total: Int,
progress: ObjRef<Int> = ObjRef(0)
): DocumentFile? {
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
dstRootDirectory?.let { it.value = dstDir }
for (child in src.listFiles()) {
yield()
if (child.isFile) {
val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child
val outputStream = contentResolver.openOutputStream(dstFile.uri)
val inputStream = contentResolver.openInputStream(child.uri)
if (outputStream == null || inputStream == null) return child
val written = inputStream.copyTo(outputStream)
outputStream.close()
inputStream.close()
if (written != child.length()) return child
} else {
recursiveCopyVolume(child, dstDir, null, taskId, total, progress)?.let { return it }
}
progress.value++
updateNotificationProgress(taskId, progress.value, total)
}
return null
}
class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
val dstRootDirectory = ObjRef<DocumentFile?>(null)
val result = globalTask(R.string.copy_volume_notification, null, { taskId ->
val total = recursiveCountChildElements(src)
updateNotificationProgress(taskId, 0, total)
recursiveCopyVolume(src, dst, dstRootDirectory, taskId, total)
}, {
dstRootDirectory.value?.delete()
})
return CopyVolumeResult(result, dstRootDirectory.value)
} }
} }

View File

@ -1,19 +0,0 @@
package sushi.hardcore.droidfs.file_operations
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
class NotificationBroadcastReceiver: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == FileOperationService.ACTION_CANCEL){
intent.getBundleExtra("bundle")?.let { bundle ->
(bundle.getBinder("binder") as FileOperationService.LocalBinder?)?.let { binder ->
val notificationId = bundle.getInt("notificationId")
val service = binder.getService()
service.cancelOperation(notificationId)
}
}
}
}
}

View File

@ -1,11 +1,22 @@
package sushi.hardcore.droidfs.file_operations package sushi.hardcore.droidfs.file_operations
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
class OperationFile(val srcPath: String, val type: Int, var dstPath: String? = null, var overwriteConfirmed: Boolean = false) {
val isDirectory = type == Stat.S_IFDIR
val name: String by lazy {
File(srcPath).name
}
val parentPath by lazy {
PathUtils.getParentPath(srcPath)
}
class OperationFile(val explorerElement: ExplorerElement, var dstPath: String? = null, var overwriteConfirmed: Boolean = false) {
companion object { companion object {
fun fromExplorerElement(e: ExplorerElement): OperationFile { fun fromExplorerElement(e: ExplorerElement): OperationFile {
return OperationFile(e, null) return OperationFile(e.fullPath, e.stat.type)
} }
} }
} }

View File

@ -0,0 +1,47 @@
package sushi.hardcore.droidfs.file_operations
import android.content.Context
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.Theme
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
class TaskResult<T> private constructor(val state: State, val failedItem: T?, val errorMessage: String?) {
enum class State {
SUCCESS,
/**
* Task completed but failed
*/
FAILED,
/**
* Task thrown an exception
*/
ERROR,
CANCELLED,
}
fun showErrorAlertDialog(context: Context, theme: Theme) {
CustomAlertDialogBuilder(context, theme)
.setTitle(R.string.error)
.setMessage(context.getString(R.string.task_failed, errorMessage))
.setPositiveButton(R.string.ok, null)
.show()
}
companion object {
fun <T> completed(failedItem: T?): TaskResult<T> {
return if (failedItem == null) {
TaskResult(State.SUCCESS, null, null)
} else {
TaskResult(State.FAILED, failedItem, null)
}
}
fun <T> error(errorMessage: String?): TaskResult<T> {
return TaskResult(State.ERROR, null, errorMessage)
}
fun <T> cancelled(): TaskResult<T> {
return TaskResult(State.CANCELLED, null, null)
}
}
}

View File

@ -1,9 +1,11 @@
package sushi.hardcore.droidfs.file_viewers package sushi.hardcore.droidfs.file_viewers
import com.google.android.exoplayer2.SimpleExoPlayer import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import sushi.hardcore.droidfs.databinding.ActivityAudioPlayerBinding import sushi.hardcore.droidfs.databinding.ActivityAudioPlayerBinding
import java.io.File
@OptIn(UnstableApi::class)
class AudioPlayer: MediaPlayer(){ class AudioPlayer: MediaPlayer(){
private lateinit var binding: ActivityAudioPlayerBinding private lateinit var binding: ActivityAudioPlayerBinding
@ -11,28 +13,17 @@ class AudioPlayer: MediaPlayer(){
binding = ActivityAudioPlayerBinding.inflate(layoutInflater) binding = ActivityAudioPlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
super.viewFile() super.viewFile()
refreshFileName()
} }
override fun getFileType(): String { override fun getFileType(): String {
return "audio" return "audio"
} }
override fun bindPlayer(player: SimpleExoPlayer) { override fun bindPlayer(player: ExoPlayer) {
binding.audioController.player = player binding.audioController.player = player
} }
override fun onPlaylistIndexChanged() { override fun onNewFileName(fileName: String) {
refreshFileName() binding.musicTitle.text = fileName
}
private fun refreshFileName() {
val filename = File(filePath).name
val pos = filename.lastIndexOf('.')
binding.musicTitle.text = if (pos != -1){
filename.substring(0,pos)
} else {
filename
}
} }
} }

View File

@ -0,0 +1,69 @@
package sushi.hardcore.droidfs.file_viewers
import android.net.Uri
import androidx.media3.common.C
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import kotlin.math.min
@OptIn(UnstableApi::class)
class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, private val filePath: String):
DataSource {
private var fileHandle = -1L
private var fileOffset: Long = 0
private var bytesRemaining: Long = -1
override fun open(dataSpec: DataSpec): Long {
fileHandle = encryptedVolume.openFileReadMode(filePath)
fileOffset = dataSpec.position
val fileSize = encryptedVolume.getAttr(filePath)!!.size
bytesRemaining = if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
fileSize - fileOffset
} else {
min(fileSize, dataSpec.length)
}
return bytesRemaining
}
override fun getUri(): Uri {
return Constants.FAKE_URI
}
override fun close() {
encryptedVolume.closeFile(fileHandle)
}
override fun addTransferListener(transferListener: TransferListener) {
//too lazy to implement this
}
override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int {
val originalOffset = fileOffset
while (fileOffset < originalOffset+readLength && encryptedVolume.read(
fileHandle,
fileOffset,
buffer,
offset+(fileOffset-originalOffset),
(originalOffset+readLength)-fileOffset
).also { fileOffset += it } > 0
) {}
val totalRead = fileOffset-originalOffset
bytesRemaining -= totalRead
return if (totalRead == 0L) {
C.RESULT_END_OF_INPUT
} else {
totalRead.toInt()
}
}
class Factory(private val encryptedVolume: EncryptedVolume, private val filePath: String): DataSource.Factory {
override fun createDataSource(): DataSource {
return EncryptedVolumeDataSource(encryptedVolume, filePath)
}
}
}

View File

@ -1,114 +1,144 @@
package sushi.hardcore.droidfs.file_viewers package sushi.hardcore.droidfs.file_viewers
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updateMargins
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sushi.hardcore.droidfs.BaseActivity import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.GocryptfsVolume
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.util.finishOnClose
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
abstract class FileViewerActivity: BaseActivity() { abstract class FileViewerActivity: BaseActivity() {
protected lateinit var gocryptfsVolume: GocryptfsVolume protected lateinit var encryptedVolume: EncryptedVolume
protected lateinit var filePath: String protected lateinit var filePath: String
private lateinit var originalParentPath: String private lateinit var originalParentPath: String
private lateinit var windowInsetsController: WindowInsetsControllerCompat private lateinit var windowInsetsController: WindowInsetsControllerCompat
private var windowTypeMask = 0 private var windowTypeMask = 0
private var isFinishingIntentionally = false
private var usf_keep_open = false
private var foldersFirst = true private var foldersFirst = true
private var wasMapped = false private var wasMapped = false
protected val mappedPlaylist = mutableListOf<ExplorerElement>() protected val mappedPlaylist = mutableListOf<ExplorerElement>()
protected var currentPlaylistIndex = -1 protected var currentPlaylistIndex = -1
private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
filePath = intent.getStringExtra("path")!! filePath = intent.getStringExtra("path")!!
originalParentPath = PathUtils.getParentPath(filePath) originalParentPath = PathUtils.getParentPath(filePath)
val sessionID = intent.getIntExtra("sessionID", -1) encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
gocryptfsVolume = GocryptfsVolume(sessionID) intent.getIntExtra("volumeId", -1)
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false) )!!
finishOnClose(encryptedVolume)
foldersFirst = sharedPrefs.getBoolean("folders_first", true) foldersFirst = sharedPrefs.getBoolean("folders_first", true)
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView) windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask -> windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
windowTypeMask = typeMask windowTypeMask = typeMask
} }
hideSystemUi() windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
viewFile() viewFile()
} }
open fun hideSystemUi(){ open fun showPartialSystemUi() {
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) if (isLegacyFullscreen) {
@Suppress("Deprecation")
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
} else {
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
windowInsetsController.show(WindowInsetsCompat.Type.navigationBars())
}
}
open fun hideSystemUi() {
if (isLegacyFullscreen) {
@Suppress("Deprecation")
window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_LOW_PROFILE or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
} else {
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setDecorFitsSystemWindows(false)
}
}
}
protected fun applyNavigationBarMargin(root: View) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
root.updateLayoutParams<FrameLayout.LayoutParams> {
val newInsets = insets.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars())
this.updateMargins(
left = newInsets.left,
top = newInsets.top,
right = newInsets.right,
bottom = newInsets.bottom
)
}
insets
}
} else {
root.fitsSystemWindows = true
}
} }
abstract fun getFileType(): String abstract fun getFileType(): String
abstract fun viewFile() abstract fun viewFile()
override fun onUserInteraction() { protected fun loadWholeFile(path: String, fileSize: Long? = null, callback: (ByteArray) -> Unit) {
super.onUserInteraction() lifecycleScope.launch(Dispatchers.IO) {
if (windowTypeMask and WindowInsetsCompat.Type.statusBars() == 0) { val result = encryptedVolume.loadWholeFile(path, size = fileSize)
hideSystemUi() if (isActive) {
} withContext(Dispatchers.Main) {
} if (result.second == 0) {
callback(result.first!!)
protected fun loadWholeFile(path: String): ByteArray? { } else {
val fileSize = gocryptfsVolume.getSize(path) val dialog = CustomAlertDialogBuilder(this@FileViewerActivity, theme)
if (fileSize >= 0){ .setTitle(R.string.error)
try { .setCancelable(false)
val fileBuff = ByteArray(fileSize.toInt()) .setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() }
var success = false when (result.second) {
val handleID = gocryptfsVolume.openReadMode(path) 1 -> dialog.setMessage(R.string.get_size_failed)
if (handleID != -1) { 2 -> dialog.setMessage(R.string.outofmemoryerror_msg)
var offset: Long = 0 3 -> dialog.setMessage(R.string.read_file_failed)
val ioBuffer = ByteArray(GocryptfsVolume.DefaultBS) 4 -> dialog.setMessage(R.string.io_error)
var length: Int }
while (gocryptfsVolume.readFile(handleID, offset, ioBuffer).also { length = it } > 0){ dialog.show()
System.arraycopy(ioBuffer, 0, fileBuff, offset.toInt(), length)
offset += length.toLong()
} }
gocryptfsVolume.closeFile(handleID)
success = offset == fileBuff.size.toLong()
} }
if (success){
return fileBuff
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.read_file_failed)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() }
.show()
}
} catch (e: OutOfMemoryError){
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.outofmemoryerror_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() }
.show()
} }
} else {
ColoredAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(R.string.get_size_failed)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() }
.show()
} }
return null
} }
protected fun createPlaylist() { protected fun createPlaylist() {
if (!wasMapped){ if (!wasMapped){
for (e in gocryptfsVolume.recursiveMapFiles(originalParentPath)) { encryptedVolume.recursiveMapFiles(originalParentPath)?.let { elements ->
if (e.isRegularFile) { for (e in elements) {
if (ConstValues.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) { if (e.isRegularFile) {
mappedPlaylist.add(e) if (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) {
mappedPlaylist.add(e)
}
} }
} }
} }
@ -146,27 +176,6 @@ abstract class FileViewerActivity: BaseActivity() {
} }
protected fun goBackToExplorer() { protected fun goBackToExplorer() {
isFinishingIntentionally = true
finish() finish()
} }
override fun onDestroy() {
super.onDestroy()
if (!isFinishingIntentionally) {
gocryptfsVolume.close()
RestrictedFileProvider.wipeAll(this)
}
}
override fun onPause() {
super.onPause()
if (!usf_keep_open) {
finish()
}
}
override fun onBackPressed() {
super.onBackPressed()
isFinishingIntentionally = true
}
} }

View File

@ -1,60 +0,0 @@
package sushi.hardcore.droidfs.file_viewers
import android.net.Uri
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.TransferListener
import sushi.hardcore.droidfs.ConstValues
import sushi.hardcore.droidfs.GocryptfsVolume
import kotlin.math.ceil
import kotlin.math.min
class GocryptfsDataSource(private val gocryptfsVolume: GocryptfsVolume, private val filePath: String): DataSource {
private var handleID = -1
private var fileSize: Long = -1
private var fileOffset: Long = 0
override fun open(dataSpec: DataSpec): Long {
fileOffset = dataSpec.position
handleID = gocryptfsVolume.openReadMode(filePath)
fileSize = gocryptfsVolume.getSize(filePath)
return fileSize
}
override fun getUri(): Uri {
return ConstValues.fakeUri
}
override fun close() {
gocryptfsVolume.closeFile(handleID)
}
override fun addTransferListener(transferListener: TransferListener) {
//too lazy to implement this
}
override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int {
if (fileOffset >= fileSize){
return -1
}
var totalRead = 0
for (i in 0 until ceil(readLength.toDouble()/ConstValues.MAX_KERNEL_WRITE).toInt()){
val tmpReadLength = min(readLength-totalRead, ConstValues.MAX_KERNEL_WRITE)
val tmpBuff = if (fileOffset+tmpReadLength > fileSize){
ByteArray((fileSize-fileOffset).toInt())
} else {
ByteArray(tmpReadLength)
}
val read = gocryptfsVolume.readFile(handleID, fileOffset, tmpBuff)
System.arraycopy(tmpBuff, 0, buffer, offset+totalRead, read)
fileOffset += read
totalRead += read
}
return totalRead
}
class Factory(private val gocryptfsVolume: GocryptfsVolume, private val filePath: String): DataSource.Factory {
override fun createDataSource(): DataSource {
return GocryptfsDataSource(gocryptfsVolume, filePath)
}
}
}

View File

@ -9,14 +9,17 @@ import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.addCallback
import androidx.activity.viewModels
import androidx.lifecycle.ViewModel
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.RequestBuilder import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.databinding.ActivityImageViewerBinding import sushi.hardcore.droidfs.databinding.ActivityImageViewerBinding
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.ZoomableImageView import sushi.hardcore.droidfs.widgets.ZoomableImageView
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@ -30,17 +33,23 @@ class ImageViewer: FileViewerActivity() {
private const val MIN_SWIPE_DISTANCE = 150 private const val MIN_SWIPE_DISTANCE = 150
} }
class ImageViewModel : ViewModel() {
var imageBytes: ByteArray? = null
var rotationAngle: Float = 0f
}
private lateinit var fileName: String private lateinit var fileName: String
private lateinit var handler: Handler private lateinit var handler: Handler
private lateinit var glideImage: RequestBuilder<Drawable> private val imageViewModel: ImageViewModel by viewModels()
private var requestBuilder: RequestBuilder<Drawable>? = null
private var x1 = 0F private var x1 = 0F
private var x2 = 0F private var x2 = 0F
private var slideshowActive = false private var slideshowActive = false
private var rotationAngle: Float = 0F private var orientationTransformation: OrientationTransformation? = null
private var rotatedBitmap: Bitmap? = null
private val hideUI = Runnable { private val hideUI = Runnable {
binding.actionButtons.visibility = View.GONE binding.actionButtons.visibility = View.GONE
binding.actionBar.visibility = View.GONE binding.topBar.visibility = View.GONE
hideSystemUi()
} }
private val slideshowNext = Runnable { private val slideshowNext = Runnable {
if (slideshowActive){ if (slideshowActive){
@ -57,13 +66,17 @@ class ImageViewer: FileViewerActivity() {
override fun viewFile() { override fun viewFile() {
binding = ActivityImageViewerBinding.inflate(layoutInflater) binding = ActivityImageViewerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
supportActionBar?.hide()
showPartialSystemUi()
applyNavigationBarMargin(binding.root)
handler = Handler(mainLooper) handler = Handler(mainLooper)
binding.imageViewer.setOnInteractionListener(object : ZoomableImageView.OnInteractionListener { binding.imageViewer.setOnInteractionListener(object : ZoomableImageView.OnInteractionListener {
override fun onSingleTap(event: MotionEvent?) { override fun onSingleTap(event: MotionEvent?) {
handler.removeCallbacks(hideUI) handler.removeCallbacks(hideUI)
if (binding.actionButtons.visibility == View.GONE) { if (binding.actionButtons.visibility == View.GONE) {
binding.actionButtons.visibility = View.VISIBLE binding.actionButtons.visibility = View.VISIBLE
binding.actionBar.visibility = View.VISIBLE binding.topBar.visibility = View.VISIBLE
showPartialSystemUi()
handler.postDelayed(hideUI, hideDelay) handler.postDelayed(hideUI, hideDelay)
} else { } else {
hideUI.run() hideUI.run()
@ -88,21 +101,21 @@ class ImageViewer: FileViewerActivity() {
} }
}) })
binding.imageDelete.setOnClickListener { binding.imageDelete.setOnClickListener {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.keepFullScreen() .keepFullScreen()
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ ->
createPlaylist() //be sure the playlist is created before deleting if there is only one image createPlaylist() //be sure the playlist is created before deleting if there is only one image
if (gocryptfsVolume.removeFile(filePath)) { if (encryptedVolume.deleteFile(filePath)) {
playlistNext(true) playlistNext(true)
refreshPlaylist() refreshPlaylist()
if (mappedPlaylist.size == 0) { //deleted all images of the playlist if (mappedPlaylist.size == 0) { //deleted all images of the playlist
goBackToExplorer() goBackToExplorer()
} else { } else {
loadImage() loadImage(true)
} }
} else { } else {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.keepFullScreen() .keepFullScreen()
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, fileName)) .setMessage(getString(R.string.remove_failed, fileName))
@ -117,7 +130,7 @@ class ImageViewer: FileViewerActivity() {
binding.imageButtonSlideshow.setOnClickListener { binding.imageButtonSlideshow.setOnClickListener {
if (!slideshowActive){ if (!slideshowActive){
slideshowActive = true slideshowActive = true
handler.postDelayed(slideshowNext, ConstValues.slideshow_delay) handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
hideUI.run() hideUI.run()
Toast.makeText(this, R.string.slideshow_started, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.slideshow_started, Toast.LENGTH_SHORT).show()
@ -137,25 +150,39 @@ class ImageViewer: FileViewerActivity() {
swipeImage(-1F) swipeImage(-1F)
} }
} }
binding.imageRotateRight.setOnClickListener { binding.imageRotateRight.setOnClickListener { onClickRotate(90f) }
rotationAngle += 90 binding.imageRotateLeft.setOnClickListener { onClickRotate(-90f) }
rotateImage() onBackPressedDispatcher.addCallback(this) {
if (slideshowActive) {
stopSlideshow()
} else {
askSaveRotation {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
} }
binding.imageRotateLeft.setOnClickListener { loadImage(false)
rotationAngle -= 90
rotateImage()
}
loadImage()
handler.postDelayed(hideUI, hideDelay) handler.postDelayed(hideUI, hideDelay)
} }
private fun loadImage(){ private fun loadImage(newImage: Boolean) {
loadWholeFile(filePath)?.let { fileName = File(filePath).name
glideImage = Glide.with(this).load(it) binding.textFilename.text = fileName
glideImage.into(binding.imageViewer) if (newImage || imageViewModel.imageBytes == null) {
fileName = File(filePath).name loadWholeFile(filePath) {
binding.textFilename.text = fileName imageViewModel.imageBytes = it
rotationAngle = 0F requestBuilder = Glide.with(this).load(it)
requestBuilder?.into(binding.imageViewer)
imageViewModel.rotationAngle = 0f
}
} else {
requestBuilder = Glide.with(this).load(imageViewModel.imageBytes)
if (imageViewModel.rotationAngle.mod(360f) != 0f) {
rotateImage()
} else {
requestBuilder?.into(binding.imageViewer)
}
} }
} }
@ -165,14 +192,20 @@ class ImageViewer: FileViewerActivity() {
handler.postDelayed(hideUI, hideDelay) handler.postDelayed(hideUI, hideDelay)
} }
private fun onClickRotate(angle: Float) {
imageViewModel.rotationAngle += angle
binding.imageViewer.restoreZoomNormal()
rotateImage()
}
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false){ private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false){
playlistNext(deltaX < 0) playlistNext(deltaX < 0)
loadImage() loadImage(true)
if (slideshowActive){ if (slideshowActive) {
if (!slideshowSwipe) { //reset slideshow delay if user swipes if (!slideshowSwipe) { //reset slideshow delay if user swipes
handler.removeCallbacks(slideshowNext) handler.removeCallbacks(slideshowNext)
} }
handler.postDelayed(slideshowNext, ConstValues.slideshow_delay) handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
} }
} }
@ -182,36 +215,31 @@ class ImageViewer: FileViewerActivity() {
Toast.makeText(this, R.string.slideshow_stopped, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.slideshow_stopped, Toast.LENGTH_SHORT).show()
} }
override fun onBackPressed() { class OrientationTransformation(private val orientation: Float): BitmapTransformation() {
if (slideshowActive){
stopSlideshow()
} else {
askSaveRotation { super.onBackPressed() }
}
}
class RotateTransformation(private val imageViewer: ImageViewer): BitmapTransformation() { lateinit var bitmap: Bitmap
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap? { override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap? {
val matrix = Matrix() return Bitmap.createBitmap(toTransform, 0, 0, toTransform.width, toTransform.height, Matrix().apply {
matrix.postRotate(imageViewer.rotationAngle) postRotate(orientation)
imageViewer.rotatedBitmap = Bitmap.createBitmap(toTransform, 0, 0, toTransform.width, toTransform.height, matrix, true) }, true).also {
return imageViewer.rotatedBitmap bitmap = it
}
} }
override fun updateDiskCacheKey(messageDigest: MessageDigest) { override fun updateDiskCacheKey(messageDigest: MessageDigest) {
messageDigest.update("rotate${imageViewer.rotationAngle}".toByteArray()) messageDigest.update("rotate$orientation".toByteArray())
} }
} }
private fun rotateImage(){ private fun rotateImage() {
binding.imageViewer.restoreZoomNormal() orientationTransformation = OrientationTransformation(imageViewModel.rotationAngle)
glideImage.transform(RotateTransformation(this)).into(binding.imageViewer) requestBuilder?.transform(orientationTransformation)?.into(binding.imageViewer)
} }
private fun askSaveRotation(callback: () -> Unit){ private fun askSaveRotation(callback: () -> Unit){
if (rotationAngle%360 != 0f && !slideshowActive){ if (imageViewModel.rotationAngle.mod(360f) != 0f && !slideshowActive) {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.keepFullScreen() .keepFullScreen()
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage(R.string.ask_save_img_rotated) .setMessage(R.string.ask_save_img_rotated)
@ -219,18 +247,18 @@ class ImageViewer: FileViewerActivity() {
.setNeutralButton(R.string.cancel, null) .setNeutralButton(R.string.cancel, null)
.setPositiveButton(R.string.yes) { _, _ -> .setPositiveButton(R.string.yes) { _, _ ->
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
if (rotatedBitmap?.compress( if (orientationTransformation?.bitmap?.compress(
if (fileName.endsWith("png", true)){ if (fileName.endsWith("png", true)){
Bitmap.CompressFormat.PNG Bitmap.CompressFormat.PNG
} else { } else {
Bitmap.CompressFormat.JPEG Bitmap.CompressFormat.JPEG
}, 100, outputStream) == true }, 90, outputStream) == true
){ ){
if (gocryptfsVolume.importFile(ByteArrayInputStream(outputStream.toByteArray()), filePath)){ if (encryptedVolume.importFile(ByteArrayInputStream(outputStream.toByteArray()), filePath)) {
Toast.makeText(this, R.string.image_saved_successfully, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.image_saved_successfully, Toast.LENGTH_SHORT).show()
callback() callback()
} else { } else {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.keepFullScreen() .keepFullScreen()
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.file_write_failed) .setMessage(R.string.file_write_failed)
@ -238,7 +266,7 @@ class ImageViewer: FileViewerActivity() {
.show() .show()
} }
} else { } else {
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.keepFullScreen() .keepFullScreen()
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.bitmap_compress_failed) .setMessage(R.string.bitmap_compress_failed)

View File

@ -1,45 +1,43 @@
package sushi.hardcore.droidfs.file_viewers package sushi.hardcore.droidfs.file_viewers
import android.view.WindowManager import android.view.WindowManager
import com.google.android.exoplayer2.* import androidx.annotation.OptIn
import com.google.android.exoplayer2.extractor.ExtractorsFactory import androidx.media3.common.MediaItem
import com.google.android.exoplayer2.extractor.flac.FlacExtractor import androidx.media3.common.PlaybackException
import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor import androidx.media3.common.Player
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor import androidx.media3.common.VideoSize
import com.google.android.exoplayer2.extractor.mp4.Mp4Extractor import androidx.media3.common.util.UnstableApi
import com.google.android.exoplayer2.extractor.ogg.OggExtractor import androidx.media3.exoplayer.ExoPlayer
import com.google.android.exoplayer2.extractor.wav.WavExtractor import androidx.media3.exoplayer.source.MediaSource
import com.google.android.exoplayer2.source.MediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource import androidx.media3.extractor.DefaultExtractorsFactory
import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File
@OptIn(UnstableApi::class)
abstract class MediaPlayer: FileViewerActivity() { abstract class MediaPlayer: FileViewerActivity() {
private lateinit var player: SimpleExoPlayer private lateinit var player: ExoPlayer
override fun viewFile() { override fun viewFile() {
supportActionBar?.hide()
initializePlayer() initializePlayer()
refreshFileName()
} }
abstract fun bindPlayer(player: SimpleExoPlayer) abstract fun bindPlayer(player: ExoPlayer)
protected open fun onPlaylistIndexChanged() {} abstract fun onNewFileName(fileName: String)
protected open fun onPlayerReady() {} protected open fun onVideoSizeChanged(width: Int, height: Int) {}
private fun createMediaSource(filePath: String): MediaSource { private fun createMediaSource(filePath: String): MediaSource {
val dataSourceFactory = GocryptfsDataSource.Factory(gocryptfsVolume, filePath) val dataSourceFactory = EncryptedVolumeDataSource.Factory(encryptedVolume, filePath)
return ProgressiveMediaSource.Factory(dataSourceFactory, ExtractorsFactory { arrayOf( return ProgressiveMediaSource.Factory(dataSourceFactory, DefaultExtractorsFactory())
MatroskaExtractor(), .createMediaSource(MediaItem.fromUri(Constants.FAKE_URI))
Mp4Extractor(),
Mp3Extractor(),
OggExtractor(),
WavExtractor(),
FlacExtractor()
) }).createMediaSource(MediaItem.fromUri(ConstValues.fakeUri))
} }
private fun initializePlayer(){ private fun initializePlayer(){
player = SimpleExoPlayer.Builder(this).build() player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
bindPlayer(player) bindPlayer(player)
createPlaylist() createPlaylist()
for (e in mappedPlaylist) { for (e in mappedPlaylist) {
@ -49,13 +47,11 @@ abstract class MediaPlayer: FileViewerActivity() {
player.seekToDefaultPosition(currentPlaylistIndex) player.seekToDefaultPosition(currentPlaylistIndex)
player.playWhenReady = true player.playWhenReady = true
player.addListener(object : Player.Listener{ player.addListener(object : Player.Listener{
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { override fun onVideoSizeChanged(videoSize: VideoSize) {
if (playbackState == Player.STATE_READY) { onVideoSizeChanged(videoSize.width, videoSize.height)
onPlayerReady()
}
} }
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
ColoredAlertDialogBuilder(this@MediaPlayer) CustomAlertDialogBuilder(this@MediaPlayer, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(getString(R.string.playing_failed, error.errorCodeName)) .setMessage(getString(R.string.playing_failed, error.errorCodeName))
.setCancelable(false) .setCancelable(false)
@ -69,10 +65,11 @@ abstract class MediaPlayer: FileViewerActivity() {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} }
} }
override fun onPositionDiscontinuity(reason: Int) {
if (player.currentWindowIndex != currentPlaylistIndex) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
playlistNext(player.currentWindowIndex == (currentPlaylistIndex+1)%mappedPlaylist.size) if (player.repeatMode != Player.REPEAT_MODE_ONE) {
onPlaylistIndexChanged() playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % mappedPlaylist.size)
refreshFileName()
} }
} }
}) })
@ -85,4 +82,8 @@ abstract class MediaPlayer: FileViewerActivity() {
player.release() player.release()
} }
} }
private fun refreshFileName() {
onNewFileName(File(filePath).name)
}
} }

View File

@ -0,0 +1,51 @@
package sushi.hardcore.droidfs.file_viewers
import android.view.Menu
import android.view.MenuItem
import app.grapheneos.pdfviewer.PdfViewer
import java.io.ByteArrayInputStream
import java.io.File
class PdfViewer: FileViewerActivity() {
init {
applyCustomTheme = false
}
private lateinit var pdfViewer: PdfViewer
override fun getFileType(): String {
return "pdf"
}
override fun viewFile() {
pdfViewer = PdfViewer(this)
val fileName = File(filePath).name
title = fileName
val fileSize = encryptedVolume.getAttr(filePath)?.size
loadWholeFile(filePath, fileSize) {
pdfViewer.loadPdf(ByteArrayInputStream(it), fileName, fileSize)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
pdfViewer.onCreateOptionMenu(menu)
return super.onCreateOptionsMenu(menu)
}
override fun onResume() {
super.onResume()
pdfViewer.onResume()
}
override fun onDestroy() {
super.onDestroy()
pdfViewer.onDestroy()
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
return pdfViewer.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return pdfViewer.onOptionsItemSelected(item) || super.onOptionsItemSelected(item)
}
}

View File

@ -6,37 +6,34 @@ import android.text.TextWatcher
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.EditText import android.widget.EditText
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.widget.Toolbar import androidx.activity.addCallback
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.GocryptfsVolume import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
import java.io.ByteArrayInputStream
import java.io.File import java.io.File
class TextEditor: FileViewerActivity() { class TextEditor: FileViewerActivity() {
private lateinit var fileName: String private lateinit var fileName: String
private lateinit var editor: EditText private lateinit var editor: EditText
private lateinit var toolbar: Toolbar
private lateinit var titleText: TextView
private var changedSinceLastSave = false private var changedSinceLastSave = false
private var wordWrap = true private var wordWrap = true
override fun hideSystemUi() {
//don't hide system ui
}
override fun getFileType(): String { override fun getFileType(): String {
return "text" return "text"
} }
override fun viewFile() { override fun viewFile() {
loadWholeFile(filePath)?.let { fileName = File(filePath).name
fileName = File(filePath).name title = fileName
supportActionBar?.setDisplayHomeAsUpEnabled(true)
loadWholeFile(filePath) {
try { try {
loadLayout(String(it)) loadLayout(String(it))
onBackPressedDispatcher.addCallback(this) {
checkSaveAndExit()
}
} catch (e: OutOfMemoryError){ } catch (e: OutOfMemoryError){
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.outofmemoryerror_msg) .setMessage(R.string.outofmemoryerror_msg)
.setCancelable(false) .setCancelable(false)
@ -51,11 +48,6 @@ class TextEditor: FileViewerActivity() {
} else { } else {
setContentView(R.layout.activity_text_editor) setContentView(R.layout.activity_text_editor)
} }
toolbar = findViewById(R.id.toolbar)
setSupportActionBar(toolbar)
title = ""
titleText = findViewById(R.id.title_text)
titleText.text = fileName
editor = findViewById(R.id.text_editor) editor = findViewById(R.id.text_editor)
editor.setText(fileContent) editor.setText(fileContent)
editor.addTextChangedListener(object: TextWatcher { editor.addTextChangedListener(object: TextWatcher {
@ -67,7 +59,7 @@ class TextEditor: FileViewerActivity() {
if (!changedSinceLastSave){ if (!changedSinceLastSave){
changedSinceLastSave = true changedSinceLastSave = true
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
titleText.text = "*$fileName" title = "*$fileName"
} }
} }
}) })
@ -75,25 +67,14 @@ class TextEditor: FileViewerActivity() {
private fun save(): Boolean{ private fun save(): Boolean{
var success = false var success = false
val content = editor.text.toString().toByteArray() val content = editor.text.toString().toByteArray()
val handleID = gocryptfsVolume.openWriteMode(filePath) val fileHandle = encryptedVolume.openFileWriteMode(filePath)
if (handleID != -1){ if (fileHandle != -1L) {
val buff = ByteArrayInputStream(content)
var offset: Long = 0 var offset: Long = 0
val ioBuffer = ByteArray(GocryptfsVolume.DefaultBS) while (offset < content.size && encryptedVolume.write(fileHandle, offset, content, offset, content.size.toLong()).also { offset += it } > 0) {}
var length: Int
while (buff.read(ioBuffer).also { length = it } > 0) {
val written = gocryptfsVolume.writeFile(handleID, offset, ioBuffer, length).toLong()
if (written == length.toLong()) {
offset += written
} else {
break
}
}
if (offset == content.size.toLong()){ if (offset == content.size.toLong()){
success = gocryptfsVolume.truncate(handleID, offset) success = encryptedVolume.truncate(filePath, offset)
} }
gocryptfsVolume.closeFile(handleID) encryptedVolume.closeFile(fileHandle)
buff.close()
} }
if (success){ if (success){
Toast.makeText(this, getString(R.string.file_saved), Toast.LENGTH_SHORT).show() Toast.makeText(this, getString(R.string.file_saved), Toast.LENGTH_SHORT).show()
@ -105,7 +86,7 @@ class TextEditor: FileViewerActivity() {
private fun checkSaveAndExit(){ private fun checkSaveAndExit(){
if (changedSinceLastSave){ if (changedSinceLastSave){
ColoredAlertDialogBuilder(this) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage(R.string.ask_save) .setMessage(R.string.ask_save)
.setPositiveButton(R.string.save) { _, _ -> .setPositiveButton(R.string.save) { _, _ ->
@ -122,7 +103,6 @@ class TextEditor: FileViewerActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.text_editor, menu) menuInflater.inflate(R.menu.text_editor, menu)
toolbar.setNavigationIcon(R.drawable.icon_arrow_back)
menu.findItem(R.id.word_wrap).isChecked = wordWrap menu.findItem(R.id.word_wrap).isChecked = wordWrap
return true return true
} }
@ -135,7 +115,7 @@ class TextEditor: FileViewerActivity() {
R.id.menu_save -> { R.id.menu_save -> {
if (save()){ if (save()){
changedSinceLastSave = false changedSinceLastSave = false
titleText.text = fileName title = fileName
} }
} }
R.id.word_wrap -> { R.id.word_wrap -> {
@ -147,8 +127,4 @@ class TextEditor: FileViewerActivity() {
} }
return true return true
} }
override fun onBackPressed() {
checkSaveAndExit()
}
} }

View File

@ -1,7 +1,10 @@
package sushi.hardcore.droidfs.file_viewers package sushi.hardcore.droidfs.file_viewers
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import com.google.android.exoplayer2.SimpleExoPlayer import android.content.res.Configuration
import android.view.View
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import sushi.hardcore.droidfs.databinding.ActivityVideoPlayerBinding import sushi.hardcore.droidfs.databinding.ActivityVideoPlayerBinding
class VideoPlayer: MediaPlayer() { class VideoPlayer: MediaPlayer() {
@ -14,20 +17,45 @@ class VideoPlayer: MediaPlayer() {
override fun viewFile() { override fun viewFile() {
binding = ActivityVideoPlayerBinding.inflate(layoutInflater) binding = ActivityVideoPlayerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
applyNavigationBarMargin(binding.root)
binding.videoPlayer.doubleTapOverlay = binding.doubleTapOverlay
binding.videoPlayer.setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { visibility ->
binding.topBar.visibility = visibility
if (visibility == View.VISIBLE) {
showPartialSystemUi()
} else {
hideSystemUi()
}
})
binding.rotateButton.setOnClickListener {
requestedOrientation =
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
}
}
super.viewFile() super.viewFile()
} }
override fun bindPlayer(player: SimpleExoPlayer) { override fun bindPlayer(player: ExoPlayer) {
binding.videoPlayer.player = player binding.videoPlayer.player = player
} }
override fun onNewFileName(fileName: String) {
binding.textFileName.text = fileName
}
override fun getFileType(): String { override fun getFileType(): String {
return "video" return "video"
} }
override fun onPlayerReady() { override fun onVideoSizeChanged(width: Int, height: Int) {
if (firstPlay && autoFit) { if (firstPlay && autoFit) {
requestedOrientation = if (binding.videoPlayer.width < binding.videoPlayer.height) ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE requestedOrientation = if (width < height)
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
firstPlay = false firstPlay = false
} }
} }

View File

@ -0,0 +1,163 @@
package sushi.hardcore.droidfs.filesystems
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.PathUtils
class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
companion object {
init {
System.loadLibrary("cryfs_jni")
}
const val CONFIG_FILE_NAME = "cryfs.config"
private external fun nativeInit(
baseDir: String,
localStateDir: String,
password: ByteArray?,
givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>?,
createBaseDir: Boolean,
cipher: String?,
errorCode: ObjRef<Int?>,
): Long
private external fun nativeChangeEncryptionKey(
baseDir: String,
localStateDir: String,
currentPassword: ByteArray?,
givenHash: ByteArray?,
newPassword: ByteArray,
returnedHash: ObjRef<ByteArray?>?
): Boolean
private external fun nativeCreate(fusePtr: Long, path: String, mode: Int): Long
private external fun nativeOpen(fusePtr: Long, path: String, flags: Int): Long
private external fun nativeRead(fusePtr: Long, fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int
private external fun nativeWrite(fusePtr: Long, fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int
private external fun nativeTruncate(fusePtr: Long, path: String, size: Long): Boolean
private external fun nativeDeleteFile(fusePtr: Long, path: String): Boolean
private external fun nativeCloseFile(fusePtr: Long, fileHandle: Long): Boolean
private external fun nativeReadDir(fusePtr: Long, path: String): MutableList<ExplorerElement>?
private external fun nativeMkdir(fusePtr: Long, path: String, mode: Int): Boolean
private external fun nativeRmdir(fusePtr: Long, path: String): Boolean
private external fun nativeGetAttr(fusePtr: Long, path: String): Stat?
private external fun nativeRename(fusePtr: Long, srcPath: String, dstPath: String): Boolean
private external fun nativeClose(fusePtr: Long)
private external fun nativeIsClosed(fusePtr: Long): Boolean
fun getLocalStateDir(filesDir: String): String {
return PathUtils.pathJoin(filesDir, Constants.CRYFS_LOCAL_STATE_DIR)
}
private fun init(
baseDir: String,
localStateDir: String,
password: ByteArray?,
givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>?,
createBaseDir: Boolean,
cipher: String?
): InitResult {
val errorCode = ObjRef<Int?>(null)
val fusePtr = nativeInit(baseDir, localStateDir, password, givenHash, returnedHash, createBaseDir, cipher, errorCode)
val result = InitResult.Builder()
if (fusePtr == 0L) {
result.errorCode = errorCode.value ?: 0
result.errorStringId = when (errorCode.value) {
// Values from src/cryfs/impl/ErrorCodes.h
11 -> {
result.worthRetry = true
R.string.wrong_password
}
16 -> R.string.inaccessible_base_dir
19 -> R.string.config_load_error
20 -> R.string.filesystem_id_changed
else -> 0
}
} else {
result.volume = CryfsVolume(fusePtr)
}
return result.build()
}
fun create(baseDir: String, localStateDir: String, password: ByteArray, returnedHash: ObjRef<ByteArray?>?, cipher: String?): EncryptedVolume? {
return init(baseDir, localStateDir, password, null, returnedHash, true, cipher).volume
}
fun init(baseDir: String, localStateDir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ObjRef<ByteArray?>?): InitResult {
return init(baseDir, localStateDir, password, givenHash, returnedHash, false, null)
}
fun changePassword(
baseDir: String, filesDir: String, currentPassword: ByteArray?,
givenHash: ByteArray?,
newPassword: ByteArray,
returnedHash: ObjRef<ByteArray?>?
): Boolean {
return nativeChangeEncryptionKey(baseDir, getLocalStateDir(filesDir), currentPassword, givenHash, newPassword, returnedHash)
}
}
override fun openFileReadMode(path: String): Long {
return nativeOpen(fusePtr, path, 0)
}
override fun openFileWriteMode(path: String): Long {
val fileHandle = nativeOpen(fusePtr, path, 0)
return if (fileHandle == -1L) {
nativeCreate(fusePtr, path, 0)
} else {
fileHandle
}
}
override fun read(fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int {
return nativeRead(fusePtr, fileHandle, fileOffset, buffer, dstOffset, length)
}
override fun write(fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int {
return nativeWrite(fusePtr, fileHandle, fileOffset, buffer, srcOffset, length)
}
override fun truncate(path: String, size: Long): Boolean {
return nativeTruncate(fusePtr, path, size)
}
override fun closeFile(fileHandle: Long): Boolean {
return nativeCloseFile(fusePtr, fileHandle)
}
override fun deleteFile(path: String): Boolean {
return nativeDeleteFile(fusePtr, path)
}
override fun readDir(path: String): MutableList<ExplorerElement>? {
return nativeReadDir(fusePtr, path)
}
override fun mkdir(path: String): Boolean {
return nativeMkdir(fusePtr, path, Stat.S_IFDIR)
}
override fun rmdir(path: String): Boolean {
return nativeRmdir(fusePtr, path)
}
override fun getAttr(path: String): Stat? {
return nativeGetAttr(fusePtr, path)
}
override fun rename(srcPath: String, dstPath: String): Boolean {
return nativeRename(fusePtr, srcPath, dstPath)
}
override fun close() {
return nativeClose(fusePtr)
}
override fun isClosed(): Boolean {
return nativeIsClosed(fusePtr)
}
}

View File

@ -0,0 +1,219 @@
package sushi.hardcore.droidfs.filesystems
import android.content.Context
import android.net.Uri
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.VolumeData
import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.Observable
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
interface Observer {
fun onClose()
}
class InitResult(
val errorCode: Int,
val errorStringId: Int,
val worthRetry: Boolean,
val volume: EncryptedVolume?,
) {
class Builder {
var errorCode = 0
var errorStringId = 0
var worthRetry = false
var volume: EncryptedVolume? = null
fun build() = InitResult(errorCode, errorStringId, worthRetry, volume)
}
}
companion object {
const val GOCRYPTFS_VOLUME_TYPE: Byte = 0
const val CRYFS_VOLUME_TYPE: Byte = 1
/**
* Get the type of a volume.
*
* @return The volume type or -1 if the path is not recognized as a volume
*/
fun getVolumeType(path: String): Byte {
return if (File(path, GocryptfsVolume.CONFIG_FILE_NAME).isFile) {
GOCRYPTFS_VOLUME_TYPE
} else if (File(path, CryfsVolume.CONFIG_FILE_NAME).isFile) {
CRYFS_VOLUME_TYPE
} else {
-1
}
}
fun init(
volume: VolumeData,
filesDir: String,
password: ByteArray?,
givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>?
): InitResult {
return when (volume.type) {
GOCRYPTFS_VOLUME_TYPE -> {
GocryptfsVolume.init(
volume.getFullPath(filesDir),
password,
givenHash,
returnedHash?.apply {
value = ByteArray(GocryptfsVolume.KeyLen)
}?.value
)
}
CRYFS_VOLUME_TYPE -> {
CryfsVolume.init(volume.getFullPath(filesDir), CryfsVolume.getLocalStateDir(filesDir), password, givenHash, returnedHash)
}
else -> throw invalidVolumeType()
}
}
private fun invalidVolumeType(): java.lang.RuntimeException {
return RuntimeException("Invalid volume type")
}
}
abstract fun openFileReadMode(path: String): Long
abstract fun openFileWriteMode(path: String): Long
abstract fun read(fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int
abstract fun write(fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int
abstract fun closeFile(fileHandle: Long): Boolean
// Due to gocryptfs internals, truncate requires the file to be open before it is called
abstract fun truncate(path: String, size: Long): Boolean
abstract fun deleteFile(path: String): Boolean
abstract fun readDir(path: String): MutableList<ExplorerElement>?
abstract fun mkdir(path: String): Boolean
abstract fun rmdir(path: String): Boolean
abstract fun getAttr(path: String): Stat?
abstract fun rename(srcPath: String, dstPath: String): Boolean
protected abstract fun close()
abstract fun isClosed(): Boolean
fun closeVolume() {
observers.forEach { it.onClose() }
close()
}
fun pathExists(path: String): Boolean {
return getAttr(path) != null
}
fun exportFile(fileHandle: Long, os: OutputStream): Boolean {
var offset: Long = 0
val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
var length: Int
while (read(fileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it } > 0) {
os.write(ioBuffer, 0, length)
offset += length.toLong()
}
os.close()
return true
}
fun exportFile(src_path: String, os: OutputStream): Boolean {
var success = false
val srcfileHandle = openFileReadMode(src_path)
if (srcfileHandle != -1L) {
success = exportFile(srcfileHandle, os)
closeFile(srcfileHandle)
}
return success
}
fun exportFile(src_path: String, dst_path: String): Boolean {
return exportFile(src_path, FileOutputStream(dst_path))
}
fun exportFile(context: Context, src_path: String, output_path: Uri): Boolean {
val os = context.contentResolver.openOutputStream(output_path)
if (os != null) {
return exportFile(src_path, os)
}
return false
}
fun importFile(inputStream: InputStream, dst_path: String): Boolean {
val dstfileHandle = openFileWriteMode(dst_path)
if (dstfileHandle != -1L) {
var success = true
var offset: Long = 0
val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
var length: Long
while (inputStream.read(ioBuffer).also { length = it.toLong() } > 0) {
val written = write(dstfileHandle, offset, ioBuffer, 0, length).toLong()
if (written == length) {
offset += written
} else {
success = false
break
}
}
truncate(dst_path, offset)
closeFile(dstfileHandle)
inputStream.close()
return success
}
return false
}
fun importFile(context: Context, src_uri: Uri, dst_path: String): Boolean {
val inputStream = context.contentResolver.openInputStream(src_uri)
if (inputStream != null) {
return importFile(inputStream, dst_path)
}
return false
}
fun loadWholeFile(fullPath: String, size: Long? = null, maxSize: Long? = null): Pair<ByteArray?, Int> {
val fileSize = size ?: getAttr(fullPath)?.size ?: -1
return if (fileSize >= 0) {
maxSize?.let {
if (fileSize > it) {
return Pair(null, 0)
}
}
try {
val fileBuff = ByteArray(fileSize.toInt())
val fileHandle = openFileReadMode(fullPath)
if (fileHandle == -1L) {
Pair(null, 3)
} else {
var offset: Long = 0
while (offset < fileSize && read(fileHandle, offset, fileBuff, offset, fileSize-offset).also { offset += it } > 0) {}
closeFile(fileHandle)
if (offset == fileBuff.size.toLong()) {
Pair(fileBuff, 0)
} else {
Pair(null, 4)
}
}
} catch (e: OutOfMemoryError) {
Pair(null, 2)
}
} else {
Pair(null, 1)
}
}
fun recursiveMapFiles(rootPath: String): MutableList<ExplorerElement>? {
val result = mutableListOf<ExplorerElement>()
val explorerElements = readDir(rootPath) ?: return null
result.addAll(explorerElements)
for (e in explorerElements) {
if (e.isDirectory) {
result.addAll(recursiveMapFiles(e.fullPath) ?: return null)
}
}
return result
}
}

View File

@ -0,0 +1,158 @@
package sushi.hardcore.droidfs.filesystems
import android.util.Log
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.util.ObjRef
import kotlin.math.min
class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
private external fun native_close(sessionID: Int)
private external fun native_is_closed(sessionID: Int): Boolean
private external fun native_list_dir(sessionID: Int, dir_path: String): MutableList<ExplorerElement>?
private external fun native_open_read_mode(sessionID: Int, file_path: String): Int
private external fun native_open_write_mode(sessionID: Int, file_path: String, mode: Int): Int
private external fun native_read_file(sessionID: Int, handleID: Int, fileOffset: Long, buff: ByteArray, dstOffset: Long, length: Int): Int
private external fun native_write_file(sessionID: Int, handleID: Int, fileOffset: Long, buff: ByteArray, srcOffset: Long, length: Int): Int
private external fun native_truncate(sessionID: Int, path: String, offset: Long): Boolean
private external fun native_close_file(sessionID: Int, handleID: Int)
private external fun native_remove_file(sessionID: Int, file_path: String): Boolean
private external fun native_mkdir(sessionID: Int, dir_path: String, mode: Int): Boolean
private external fun native_rmdir(sessionID: Int, dir_path: String): Boolean
private external fun native_get_attr(sessionID: Int, file_path: String): Stat?
private external fun native_rename(sessionID: Int, old_path: String, new_path: String): Boolean
companion object {
const val KeyLen = 32
private const val ScryptDefaultLogN = 16
private const val VOLUME_CREATOR = "DroidFS"
private const val MAX_KERNEL_WRITE = 128*1024
const val CONFIG_FILE_NAME = "gocryptfs.conf"
private external fun nativeCreateVolume(
root_cipher_dir: String,
password: ByteArray,
plainTextNames: Boolean,
xchacha: Int,
logN: Int,
creator: String,
returnedHash: ByteArray?,
): Int
private external fun nativeInit(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): Int
external fun changePassword(
root_cipher_dir: String,
currentPassword: ByteArray?,
givenHash: ByteArray?,
newPassword: ByteArray,
returnedHash: ByteArray?
): Boolean
fun createAndOpenVolume(
root_cipher_dir: String,
password: ByteArray,
plainTextNames: Boolean,
xchacha: Int,
returnedHash: ByteArray?,
volume: ObjRef<EncryptedVolume?>
): Boolean {
return when (val result = nativeCreateVolume(
root_cipher_dir,
password,
plainTextNames,
xchacha,
ScryptDefaultLogN,
VOLUME_CREATOR,
returnedHash,
)) {
-1 -> {
Log.e("gocryptfs", "Failed to open volume after creation")
true
}
-2 -> false
else -> {
volume.value = GocryptfsVolume(result)
true
}
}
}
fun init(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): InitResult {
val sessionId = nativeInit(root_cipher_dir, password, givenHash, returnedHash)
val result = InitResult.Builder()
if (sessionId < 0) {
result.errorCode = sessionId
result.errorStringId = when (sessionId) {
-1 -> R.string.config_load_error
-2 -> {
result.worthRetry = true
R.string.wrong_password
}
else -> 0
}
} else {
result.volume = GocryptfsVolume(sessionId)
}
return result.build()
}
init {
System.loadLibrary("gocryptfs_jni")
}
}
override fun openFileReadMode(path: String): Long {
return native_open_read_mode(sessionID, path).toLong()
}
override fun openFileWriteMode(path: String): Long {
return native_open_write_mode(sessionID, path, 384).toLong() // 0600
}
override fun read(fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int {
return native_read_file(sessionID, fileHandle.toInt(), fileOffset, buffer, dstOffset, min(length.toInt(), MAX_KERNEL_WRITE))
}
override fun readDir(path: String): MutableList<ExplorerElement>? {
return native_list_dir(sessionID, path)
}
override fun getAttr(path: String): Stat? {
return native_get_attr(sessionID, path)
}
override fun close() {
native_close(sessionID)
}
override fun isClosed(): Boolean {
return native_is_closed(sessionID)
}
override fun mkdir(path: String): Boolean {
return native_mkdir(sessionID, path, 448) // 0700
}
override fun rmdir(path: String): Boolean {
return native_rmdir(sessionID, path)
}
override fun closeFile(fileHandle: Long): Boolean {
native_close_file(sessionID, fileHandle.toInt())
return true
}
override fun write(fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int {
return native_write_file(sessionID, fileHandle.toInt(), fileOffset, buffer, srcOffset, min(length.toInt(), MAX_KERNEL_WRITE))
}
override fun truncate(path: String, size: Long): Boolean {
return native_truncate(sessionID, path, size)
}
override fun deleteFile(path: String): Boolean {
return native_remove_file(sessionID, path)
}
override fun rename(srcPath: String, dstPath: String): Boolean {
return native_rename(sessionID, srcPath, dstPath)
}
}

View File

@ -0,0 +1,17 @@
package sushi.hardcore.droidfs.filesystems
class Stat(mode: Int, var size: Long, val mTime: Long) {
companion object {
private const val S_IFMT = 0xF000
const val S_IFDIR = 0x4000
const val S_IFREG = 0x8000
const val S_IFLNK = 0xA000
const val PARENT_FOLDER_TYPE = 0xE000
fun parentFolderStat(): Stat {
return Stat(PARENT_FOLDER_TYPE, -1, -1)
}
}
val type = mode and S_IFMT
}

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