Compare commits

...

83 Commits

Author SHA1 Message Date
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
Matéo Duparc 6f43bc7417
Avoid being killed by SELinux when retrieving volume path 2024-02-11 17:55:24 +01:00
Matéo Duparc c26ab661c2
Logcat activity 2024-01-30 18:29:49 +01:00
Matéo Duparc 1c15f9fac8
Allow choosing export method 2024-01-28 15:44:53 +01:00
Matéo Duparc b4635dc2e0
Directory loading indicator 2024-01-13 23:19:22 +01:00
Matéo Duparc f4e47c1827
Allow directory creation on exposed volumes 2024-01-13 21:41:58 +01:00
Matéo Duparc 5474d6eea5
Add .opus & Update build config 2024-01-13 21:25:31 +01:00
Matéo Duparc 719faa31ee
Fix README 2023-10-15 17:09:48 +02:00
Matéo Duparc a41cde1c53
DroidFS v2.1.3 2023-09-28 19:36:55 +02:00
Matéo Duparc b503f134d5
Fix Intent.getParcelableExtra() crash on Android 13 2023-09-24 19:04:49 +02:00
Matéo Duparc 3ba774fda3
Add Version.toString() 2023-09-19 13:47:59 +02:00
Matéo Duparc b2154d319e
Repair corrupted database due to v2.1.1 2023-09-19 13:39:35 +02:00
Matéo Duparc 571a79cc1d
Really fix database upgrade 2023-09-19 11:41:01 +02:00
Matéo Duparc 891a581329
Update dependencies 2023-09-17 20:10:15 +02:00
Matéo Duparc f1a9c1383c
Fix database upgrade 2023-09-17 19:11:52 +02:00
Matéo Duparc ac71ad887d
Fix README 2023-09-10 21:39:28 +02:00
Matéo Duparc e1fe329f49
Add v2.1.0 changelog 2023-09-10 21:11:04 +02:00
Matéo Duparc dfff597ae5
DroidFS v2.1.0 2023-09-10 21:01:39 +02:00
Matéo Duparc bd429648b3
Update documentation 2023-09-10 21:01:04 +02:00
Matéo Duparc 71ff37b170
Fixes 2023-09-10 19:17:51 +02:00
Matéo Duparc 4afe56b13c
Migrate to AndroidX Media3 2023-09-10 19:12:17 +02:00
Matéo Duparc 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
Matéo Duparc d6c777875e
Fix VolumeProvider createDocument path 2023-09-08 21:22:20 +02:00
Matéo Duparc 8a18270b33
Update dependencies 2023-09-08 21:13:24 +02:00
Matéo Duparc 79db84f81d
Volume provider 2023-09-06 19:27:41 +02:00
Matéo Duparc 6d04349b2e
Prevent volume renaming when open 2023-09-06 19:27:04 +02:00
Matéo Duparc de0194a722
Always open volume after creation 2023-08-20 17:08:10 +02:00
Matéo Duparc 3127a15d9e
Fix ANR on recursive mapping 2023-08-20 16:42:40 +02:00
Matéo Duparc a08da2eacb
MemoryFileProvider 2023-08-20 14:56:46 +02:00
Matéo Duparc 1727170cb6
Limit the number of thumbnails loaded concurrently 2023-08-15 18:33:29 +02:00
Matéo Duparc 8776d2ee28
Add Support section in README 2023-08-15 18:06:39 +02:00
Matéo Duparc 5642e28b44
Fix TODO.md 2023-05-12 20:39:58 +02:00
Matéo Duparc 1b7e5904be
New screenshots 2023-05-12 20:26:45 +02:00
Matéo Duparc cb3fc3c70e
Re-ask only on wrong password 2023-05-11 21:58:55 +02:00
Matéo Duparc 393c458495
Offload file discovery for copy in coroutine 2023-05-11 21:24:29 +02:00
Matéo Duparc cdf98a7190
Handle cryfs inaccessible base dir 2023-05-11 00:02:05 +02:00
Matéo Duparc 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
Matéo Duparc 9fc981fee8
Fix rotation when rebinding camera use cases 2023-05-08 21:32:04 +02:00
Matéo Duparc ad19b9e645
Update dependencies 2023-05-08 20:58:54 +02:00
Matéo Duparc 87ffbc3cc1
Fix unsafe features doc link 2023-05-06 23:57:23 +02:00
Matéo Duparc b3a25e03e7
Improve video recording: fix freezes & ExoPlayer errors 2023-05-06 23:40:37 +02:00
Matéo Duparc 4c412be7dc
Best error messages when opening volumes 2023-05-03 14:14:40 +02:00
Matéo Duparc f4f3239bb1
Fix volume copying 2023-05-02 14:24:59 +02:00
Matéo Duparc 481558bd56
Add ecryptfs & shufflecake in TODO & Update README 2023-04-29 20:21:46 +02:00
Matéo Duparc 8d0a797469
v2.0.1 changelog 2023-04-26 17:12:38 +02:00
Matéo Duparc a4ce35c95d
WiperService 2023-04-26 16:40:05 +02:00
Matéo Duparc e51bd2ceba
TODO.md & Update dependencies 2023-04-26 16:02:07 +02:00
Matéo Duparc 2bbf003df5
Recover unregistered hidden volumes 2023-04-25 15:06:20 +02:00
Matéo Duparc 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
Matéo Duparc 49ec2eaf49
Stop always opening files in write mode 2023-04-20 16:38:15 +02:00
Matéo Duparc 8c9c6a20b9
Really fix proguard-rules.pro 2023-04-19 16:23:32 +02:00
Matéo Duparc 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
Matéo Duparc 0a089c46ca
Fix proguard-rules.pro 2023-04-19 15:34:36 +02:00
Matéo Duparc 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
Matéo Duparc df3f84f526
Target Android API level 32 2023-04-18 14:59:05 +02:00
Matéo Duparc 24215a8b31
Fix crash when default volume gets deleted 2023-04-18 13:53:40 +02:00
Matéo Duparc eb4e13af46
Disable settings buttons during video recording 2023-04-17 18:50:46 +02:00
Matéo Duparc aea17aa7cb
Update dependencies 2023-04-17 17:06:51 +02:00
Matéo Duparc e918a2f94c
New CameraX API 2023-04-17 15:52:20 +02:00
Matéo Duparc e6761d1798
Update README & fastlane full_description.txt 2023-03-15 18:08:39 +01:00
Matéo Duparc c434d79c06
Fix video title switching 2023-03-13 17:10:06 +01:00
Matéo Duparc 821c853a22
Hide navigation bar in full screen mode 2023-03-13 17:02:38 +01:00
Matéo Duparc 22b1522192
Optional password fallback 2023-03-08 12:03:05 +01:00
Matéo Duparc 5090a7aa03
Separate color selection & black theme 2023-03-08 11:43:13 +01:00
Matéo Duparc 1a1d3ea570
Multi volume openings 2023-03-07 23:25:17 +01:00
Matéo Duparc 2d165c4a20
Monospace font in text editor 2023-02-06 11:16:10 +01:00
Matéo Duparc 883874a5ab
Refactoring: Constants & FileTypes 2023-02-06 10:52:51 +01:00
Matéo Duparc 6e500c23e5
Adaptive icon 2023-02-05 14:43:30 +01:00
Matéo Duparc a726f7a7d0
Fix storage permission requests 2023-02-02 21:09:11 +01:00
Matéo Duparc 1e75e9a32f
Clear password fields onStop() 2023-02-02 19:37:10 +01:00
Matéo Duparc 5e9656970a
Update dependencies 2023-02-01 23:46:27 +01:00
Matéo Duparc 5dbef99949
Fix EncryptedVolumeDataSource EOF 2023-02-01 20:06:35 +01:00
Matéo Duparc d2f11c85d1
Android 11 support 2023-02-01 19:09:53 +01:00
177 changed files with 16038 additions and 4320 deletions

View File

@ -45,16 +45,16 @@ $ git clone --depth=1 https://git.ffmpeg.org/ffmpeg.git
If you want Gocryptfs support, you need to download OpenSSL: If you want Gocryptfs support, you need to download OpenSSL:
``` ```
$ cd ../libgocryptfs $ cd ../libgocryptfs
$ wget https://www.openssl.org/source/openssl-1.1.1q.tar.gz $ wget https://www.openssl.org/source/openssl-1.1.1w.tar.gz
``` ```
Verify OpenSSL signature: Verify OpenSSL signature:
``` ```
$ wget https://www.openssl.org/source/openssl-1.1.1q.tar.gz.asc $ wget https://www.openssl.org/source/openssl-1.1.1w.tar.gz.asc
$ gpg --verify openssl-1.1.1q.tar.gz.asc openssl-1.1.1q.tar.gz $ gpg --verify openssl-1.1.1w.tar.gz.asc openssl-1.1.1w.tar.gz
``` ```
Continue **ONLY** if the signature is **VALID**. Continue **ONLY** if the signature is **VALID**.
``` ```
$ tar -xzf openssl-1.1.1q.tar.gz $ tar -xzf openssl-1.1.1w.tar.gz
``` ```
If you want CryFS support, initialize libcryfs: If you want CryFS support, initialize libcryfs:
``` ```
@ -62,6 +62,14 @@ $ cd app/libcryfs
$ git submodule update --depth=1 --init $ git submodule update --depth=1 --init
``` ```
To be able to open PDF files internally, [pdf.js](https://github.com/mozilla/pdf.js) must be downloaded:
```
$ mkdir libpdfviewer/app/pdfjs-dist && cd libpdfviewer/app/pdfjs-dist
$ wget https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.8.162.tgz
$ tar xf pdfjs-dist-3.8.162.tgz package/build/pdf.min.js package/build/pdf.worker.min.js
$ mv package/build . && rm pdfjs-dist-3.8.162.tgz
```
# Build # Build
Retrieve your Android NDK installation path, usually something like `/home/\<user\>/Android/SDK/ndk/\<NDK version\>`. Then, make it available in your shell: Retrieve your Android NDK installation path, usually something like `/home/\<user\>/Android/SDK/ndk/\<NDK version\>`. Then, make it available in your shell:
``` ```
@ -76,7 +84,7 @@ $ ./build.sh ffmpeg
This step is only required if you want Gocryptfs support. This step is only required if you want Gocryptfs support.
``` ```
$ cd app/libgocryptfs $ cd app/libgocryptfs
$ OPENSSL_PATH="./openssl-1.1.1q" ./build.sh $ OPENSSL_PATH="./openssl-1.1.1w" ./build.sh
``` ```
## Compile APKs ## Compile APKs
Gradle build libgocryptfs and libcryfs by default. Gradle build libgocryptfs and libcryfs by default.

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

View File

@ -1,45 +1,68 @@
# 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 supports [gocryptfs](https://github.com/rfjakob/gocryptfs) and [CryFS](https://github.com/cryfs/cryfs) (alpha). 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>
# 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 # 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 upcoming 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><h4>Allow screenshots:</h4>
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. 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>
<li><h4>Allow opening files with other applications *:</h4>
Decrypt and open file using external apps. These apps could save and send the files thus opened.
</li>
<li><h4>Allow exporting files:</h4> <li><h4>Allow exporting files:</h4>
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files. Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.
</li> </li>
<li><h4>Allow sharing files via the android share menu *:</h4> <li><h4>Allow sharing files via the android share menu*:</h4>
Decrypt and share file with other apps. These apps could save and send the files thus shared. Decrypt and share file with other apps. These apps could save and send the files thus shared.
</li> </li>
<li><h4>Keep volume open when the app goes in background:</h4>
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>
<li><h4>Allow saving password hash using fingerprint:</h4> <li><h4>Allow saving password hash using fingerprint:</h4>
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. 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>
<li><h4>Keep volume open when the app goes in background:</h4>
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>
<li><h4>Allow opening files with other applications*:</h4>
Decrypt and open file using external apps. These apps could save and send the files thus opened.
</li>
<li><h4>Expose open volumes*:</h4>
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>"Keep volume open when the app goes in background"</i> to be enabled.
</li>
<li><h4>Grant write access:</h4>
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">
@ -65,17 +88,17 @@ __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> <ul>
<li><h4>Read & write access to shared storage:</h4> <li><h4>Read & write access to shared storage:</h4>
Required for creating, opening and modifying volumes and for importing/exporting files to/from volumes. Required to access volumes located on shared storage.
</li> </li>
<li><h4>Biometric/Fingerprint hardware:</h4> <li><h4>Biometric/Fingerprint hardware:</h4>
Required to encrypt/decrypt password hashes using a fingerprint protected key. Required to encrypt/decrypt password hashes using a fingerprint protected key.
</li> </li>
<li><h4>Camera:</h4> <li><h4>Camera:</h4>
Needed to take photos & videos directly encrypted inside DroidFS. You can deny this permission if you don't want to use it. Required to take encrypted photos or videos directly from the app.
</li> </li>
<li><h4>Record audio:</h4> <li><h4>Record audio:</h4>
Required if you want sound on video recorded with DroidFS. Required if you want sound on video recorded with DroidFS.
@ -83,7 +106,7 @@ DroidFS need some permissions to work properly. Here is why:
</ul> </ul>
# Limitations # Limitations
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), nor can it allow other applications to access encrypted volumes once opened. 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.
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. 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.
@ -102,5 +125,5 @@ Thanks to these open source projects that DroidFS uses:
### Borrowed code: ### Borrowed code:
- [MaterialFiles](https://github.com/zhanghai/MaterialFiles) for Kotlin natural sorting implementation - [MaterialFiles](https://github.com/zhanghai/MaterialFiles) for Kotlin natural sorting implementation
### Libraries: ### Libraries:
- [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

31
TODO.md Normal file
View File

@ -0,0 +1,31 @@
# 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
- Optional discovery before file operations
- Modifiable CryFS 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
- F-Droid ABI split
- OpenSSL & FFmpeg as git submodules (useful for F-Droid)
- 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

@ -5,6 +5,9 @@ project(DroidFS)
option(GOCRYPTFS "build libgocryptfs" ON) option(GOCRYPTFS "build libgocryptfs" ON)
option(CRYFS "build libcryfs" ON) option(CRYFS "build libcryfs" ON)
add_library(memfile SHARED src/main/native/memfile.c)
target_link_libraries(memfile log)
if (GOCRYPTFS) if (GOCRYPTFS)
add_library(gocryptfs SHARED IMPORTED) add_library(gocryptfs SHARED IMPORTED)
set_target_properties( set_target_properties(
@ -72,4 +75,5 @@ target_link_libraries(
avformat avformat
avcodec avcodec
avutil avutil
log
) )

View File

@ -13,23 +13,32 @@ if (hasProperty("disableGocryptfs")) {
ext.disableGocryptfs = false ext.disableGocryptfs = false
} }
if (hasProperty("nosplits")) {
ext.splits = false
} else {
ext.splits = true
}
android { android {
compileSdkVersion 33 compileSdk 34
buildToolsVersion "33.0.0" ndkVersion "26.1.10909125"
ndkVersion "25.1.8937393"
namespace "sushi.hardcore.droidfs" namespace "sushi.hardcore.droidfs"
compileOptions { compileOptions {
targetCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
} }
defaultConfig { defaultConfig {
applicationId "sushi.hardcore.droidfs" applicationId "sushi.hardcore.droidfs"
minSdkVersion 21 minSdkVersion 21
//noinspection ExpiredTargetSdkVersion targetSdkVersion 32
targetSdkVersion 29 versionCode 36
versionCode 29 versionName "2.1.3"
versionName "2.0.0-alpha2"
ndk { ndk {
abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a" abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
@ -45,16 +54,17 @@ android {
} }
} }
if (!file("fdroid").exists()) { if (project.ext.splits) {
splits { splits {
abi { abi {
enable true enable true
reset() // fix unknown bug (https://ru.stackoverflow.com/questions/1557805/abis-armeabi-mips-mips64-riscv64-are-not-supported-for-platform)
universalApk true universalApk true
} }
} }
} }
applicationVariants.all { variant -> applicationVariants.configureEach { variant ->
variant.resValue "string", "versionName", variant.versionName variant.resValue "string", "versionName", variant.versionName
buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}" buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}"
buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}" buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}"
@ -62,6 +72,7 @@ android {
buildFeatures { buildFeatures {
viewBinding true viewBinding true
buildConfig true
} }
buildTypes { buildTypes {
@ -81,33 +92,46 @@ android {
path file('CMakeLists.txt') path file('CMakeLists.txt')
} }
} }
sourceSets {
main {
java {
exclude 'androidx/camera/video/originals/**'
}
}
}
} }
dependencies { dependencies {
implementation project(":libpdfviewer:app") implementation project(":libpdfviewer:app")
implementation fileTree(dir: "libs", include: ["*.jar"]) implementation 'androidx.core:core-ktx:1.12.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "androidx.appcompat:appcompat:1.6.1"
implementation 'androidx.core:core-ktx:1.9.0'
implementation "androidx.appcompat:appcompat:1.5.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.4" implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1" def lifecycle_version = "2.6.2"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
implementation "androidx.sqlite:sqlite-ktx:2.2.0" implementation "androidx.sqlite:sqlite-ktx:2.3.1"
implementation "androidx.preference:preference-ktx:1.2.0" implementation "androidx.preference:preference-ktx:1.2.1"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'com.google.android.material:material:1.6.1' implementation 'com.google.android.material:material:1.9.0'
implementation "com.github.bumptech.glide:glide:4.13.2" implementation 'com.github.bumptech.glide:glide:4.16.0'
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
def exoplayer_version = "2.18.1" def media3_version = "1.1.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:1.1.1'
implementation "androidx.media3:media3-datasource:$media3_version"
implementation "androidx.concurrent:concurrent-futures:1.1.0" implementation "androidx.concurrent:concurrent-futures:1.1.0"
def camerax_version = "1.2.0-beta02" def camerax_version = "1.3.0-rc02"
implementation "androidx.camera:camera-camera2:$camerax_version" implementation "androidx.camera:camera-camera2:$camerax_version"
implementation "androidx.camera:camera-lifecycle:$camerax_version" implementation "androidx.camera:camera-lifecycle:$camerax_version"
implementation "androidx.camera:camera-view:$camerax_version" implementation "androidx.camera:camera-view:$camerax_version"
implementation "androidx.camera:camera-extensions:$camerax_version" implementation "androidx.camera:camera-extensions:$camerax_version"
def autoValueVersion = '1.10.4'
implementation "com.google.auto.value:auto-value-annotations:$autoValueVersion"
annotationProcessor "com.google.auto.value:auto-value:$autoValueVersion"
} }

@ -1 +1 @@
Subproject commit dc82772e5eb8063bace8415193eebc52cedcec64 Subproject commit 6388eaf433a4196f10389921d5e346c90ee3d793

@ -1 +1 @@
Subproject commit 27232cbdb7257be13a12545b71fa32ee193cb11b Subproject commit 4f32853ae5ac70811b451cac60ed36fd5b93cbc8

View File

@ -1,31 +1,24 @@
# 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.MediaMuxer { -keepclassmembers class sushi.hardcore.droidfs.video_recording.FFmpegMuxer {
void writePacket(byte[]); void writePacket(byte[]);
void seek(long); 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;
}
-keep class sushi.hardcore.droidfs.filesystems.EncryptedVolume {
public int describeContents();
}
-keep class sushi.hardcore.droidfs.filesystems.EncryptedVolume$* {
static public android.os.Parcelable$Creator CREATOR;
}

View File

@ -3,10 +3,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
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"
android:protectionLevel="signature" />
<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" />
@ -24,12 +21,14 @@
tools:node="remove" /> <!--removing this permission automatically added by exoplayer--> tools:node="remove" /> <!--removing this permission automatically added by exoplayer-->
<application <application
android:allowBackup="false" android:icon="@mipmap/ic_launcher"
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/BaseTheme"> android:theme="@style/BaseTheme"
android:name=".VolumeManagerApp"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/backup_rules">
<activity android:name=".MainActivity" android:exported="true"> <activity android:name=".MainActivity" android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@ -48,13 +47,15 @@
<activity android:name=".explorers.ExplorerActivity"/> <activity android:name=".explorers.ExplorerActivity"/>
<activity android:name=".explorers.ExplorerActivityPick"/> <activity android:name=".explorers.ExplorerActivityPick"/>
<activity android:name=".explorers.ExplorerActivityDrop"/> <activity android:name=".explorers.ExplorerActivityDrop"/>
<activity android:name=".file_viewers.ImageViewer" android:configChanges="screenSize|orientation" /> <!-- don't reload content on configuration change --> <activity android:name=".file_viewers.ImageViewer"/>
<activity android:name=".file_viewers.VideoPlayer" android:configChanges="screenSize|orientation" /> <activity android:name=".file_viewers.VideoPlayer" android:configChanges="screenSize|orientation" />
<activity android:name=".file_viewers.PdfViewer" android:configChanges="screenSize|orientation" android:theme="@style/AppTheme" /> <activity android:name=".file_viewers.PdfViewer" android:configChanges="screenSize|orientation" android:theme="@style/AppTheme" />
<activity android:name=".file_viewers.AudioPlayer" android:configChanges="screenSize|orientation" /> <activity android:name=".file_viewers.AudioPlayer" android:configChanges="screenSize|orientation" />
<activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" /> <activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" />
<activity android:name=".CameraActivity" android:screenOrientation="nosensor" /> <activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
<activity android:name=".LogcatActivity"/>
<service android:name=".WiperService" android:exported="false" android:stopWithTask="false"/>
<service android:name=".file_operations.FileOperationService" android:exported="false"/> <service android:name=".file_operations.FileOperationService" android:exported="false"/>
<receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false"> <receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false">
@ -64,10 +65,21 @@
</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 new CameraX files from upstream 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

@ -4,46 +4,21 @@ import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.WindowManager import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
open class BaseActivity: AppCompatActivity() { open class BaseActivity: AppCompatActivity() {
protected lateinit var sharedPrefs: SharedPreferences protected lateinit var sharedPrefs: SharedPreferences
protected var applyCustomTheme: Boolean = true protected var applyCustomTheme: Boolean = true
lateinit var themeValue: String lateinit var theme: Theme
private var shouldCheckTheme = true
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
themeValue = sharedPrefs.getString(ConstValues.THEME_VALUE_KEY, ConstValues.DEFAULT_THEME_VALUE)!!
if (shouldCheckTheme && applyCustomTheme) {
when (themeValue) {
"black_green" -> setTheme(R.style.BlackGreen)
"dark_red" -> setTheme(R.style.DarkRed)
"black_red" -> setTheme(R.style.BlackRed)
"dark_blue" -> setTheme(R.style.DarkBlue)
"black_blue" -> setTheme(R.style.BlackBlue)
"dark_yellow" -> setTheme(R.style.DarkYellow)
"black_yellow" -> setTheme(R.style.BlackYellow)
"dark_orange" -> setTheme(R.style.DarkOrange)
"black_orange" -> setTheme(R.style.BlackOrange)
"dark_purple" -> setTheme(R.style.DarkPurple)
"black_purple" -> setTheme(R.style.BlackPurple)
"dark_pink" -> setTheme(R.style.DarkPink)
"black_pink" -> setTheme(R.style.BlackPink)
}
}
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (!sharedPrefs.getBoolean("usf_screenshot", false)){ sharedPrefs = (application as VolumeManagerApp).sharedPreferences
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)
} }
} }
// must not be called if applyCustomTheme is false
fun onThemeChanged(newThemeValue: String) {
if (newThemeValue != themeValue) {
themeValue = newThemeValue
shouldCheckTheme = false
recreate()
}
}
} }

View File

@ -2,10 +2,7 @@ package sushi.hardcore.droidfs
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
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
@ -17,25 +14,39 @@ import android.view.animation.RotateAnimation
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.activity.addCallback
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.camera.camera2.interop.Camera2CameraInfo import androidx.camera.core.AspectRatio
import androidx.camera.core.* 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.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
import sushi.hardcore.droidfs.databinding.ActivityCameraBinding import sushi.hardcore.droidfs.databinding.ActivityCameraBinding
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.IntentUtils import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
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.video_recording.SeekableWriter
import sushi.hardcore.droidfs.video_recording.VideoCapture
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog import sushi.hardcore.droidfs.widgets.EditTextDialog
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -43,7 +54,10 @@ import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
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 = 0 private const val CAMERA_PERMISSION_REQUEST_CODE = 0
@ -63,14 +77,12 @@ 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 encryptedVolume: EncryptedVolume private lateinit var encryptedVolume: EncryptedVolume
private lateinit var outputDirectory: String private lateinit var outputDirectory: String
private var isFinishingIntentionally = false
private var isAskingPermissions = 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
@ -78,11 +90,17 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
private lateinit var cameraSelector: CameraSelector 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? = 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 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 isInVideoMode = false
@ -92,7 +110,6 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
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)
supportActionBar?.hide() supportActionBar?.hide()
@ -103,7 +120,6 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){
permissionsGranted = true permissionsGranted = true
} else { } else {
isAskingPermissions = true
requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE) requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE)
} }
} else { } else {
@ -125,50 +141,76 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
} }
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"
Quality.FHD -> "FHD"
Quality.HD -> "HD"
Quality.SD -> "SD"
else -> throw IllegalArgumentException("Invalid quality: $it")
} }
CustomAlertDialogBuilder(this, themeValue) }.toTypedArray()
CustomAlertDialogBuilder(this, theme)
.setTitle("Choose quality:")
.setSingleChoiceItems(qualityNames, currentQualityIndex) { dialog, which ->
currentQualityIndex = which
rebindUseCases()
dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
} else {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.camera_optimization) .setTitle(R.string.camera_optimization)
.setSingleChoiceItems(arrayOf(getString(R.string.maximize_quality), getString(R.string.minimize_latency)), currentIndex) { dialog, which -> .setSingleChoiceItems(
val resId: Int arrayOf(getString(R.string.maximize_quality), getString(R.string.minimize_latency)),
if (captureMode == ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) 0 else 1
) { dialog, which ->
val newCaptureMode = if (which == 0) { val newCaptureMode = if (which == 0) {
resId = R.drawable.icon_high_quality
ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
} else { } else {
resId = R.drawable.icon_speed
ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
} }
if (newCaptureMode != captureMode) { if (newCaptureMode != captureMode) {
captureMode = newCaptureMode captureMode = newCaptureMode
binding.imageCaptureMode.setImageResource(resId) setCaptureModeIcon()
if (!isInVideoMode) { rebindUseCases()
cameraProvider.unbind(imageCapture)
refreshImageCapture()
cameraProvider.bindToLifecycle(this, cameraSelector, imageCapture)
}
} }
dialog.dismiss() dialog.dismiss()
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
}
binding.imageRatio.setOnClickListener { binding.imageRatio.setOnClickListener {
if (isInVideoMode) {
CustomAlertDialogBuilder(this, theme)
.setTitle("Aspect ratio:")
.setSingleChoiceItems(arrayOf("16:9", "4:3"), currentAspectRatioIndex) { dialog, which ->
currentAspectRatioIndex = which
rebindUseCases()
dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
.show()
} else {
resolutions?.let { resolutions?.let {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.choose_resolution) .setTitle(R.string.choose_resolution)
.setSingleChoiceItems(it.map { size -> size.toString() }.toTypedArray(), currentResolutionIndex) { dialog, which -> .setSingleChoiceItems(it.map { size -> size.toString() }.toTypedArray(), currentResolutionIndex) { dialog, which ->
currentResolution = resolutions!![which] currentResolution = resolutions!![which]
currentResolutionIndex = which currentResolutionIndex = which
setupCamera() rebindUseCases()
dialog.dismiss() dialog.dismiss()
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
} }
}
binding.imageTimer.setOnClickListener { binding.imageTimer.setOnClickListener {
with (EditTextDialog(this, R.string.enter_timer_duration) { with (EditTextDialog(this, R.string.enter_timer_duration) {
try { try {
@ -214,19 +256,19 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
} }
binding.imageModeSwitch.setOnClickListener { binding.imageModeSwitch.setOnClickListener {
isInVideoMode = !isInVideoMode isInVideoMode = !isInVideoMode
setupCamera() rebindUseCases()
binding.imageFlash.setImageResource(if (isInVideoMode) { binding.imageFlash.setImageResource(if (isInVideoMode) {
binding.recordVideoButton.visibility = View.VISIBLE binding.recordVideoButton.visibility = View.VISIBLE
binding.takePhotoButton.visibility = View.GONE binding.takePhotoButton.visibility = View.GONE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
isAskingPermissions = true
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), AUDIO_PERMISSION_REQUEST_CODE) requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), AUDIO_PERMISSION_REQUEST_CODE)
} }
} }
binding.imageModeSwitch.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.icon_photo)?.mutate()?.also { binding.imageModeSwitch.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.icon_photo)?.mutate()?.also {
it.setTint(ContextCompat.getColor(this, R.color.neutralIconTint)) it.setTint(ContextCompat.getColor(this, R.color.neutralIconTint))
}) })
setCaptureModeIcon()
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
R.drawable.icon_flash_off R.drawable.icon_flash_off
} else { } else {
@ -251,6 +293,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
true true
} }
resolutions = null resolutions = null
qualities = null
setupCamera() setupCamera()
} }
binding.takePhotoButton.onClick = ::onClickTakePhoto binding.takePhotoButton.onClick = ::onClickTakePhoto
@ -280,31 +323,22 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
else -> false else -> false
} }
} }
onBackPressedDispatcher.addCallback(this) {
isFinishingIntentionally = true
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
} }
@RequiresApi(Build.VERSION_CODES.M) @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)
isAskingPermissions = false
if (grantResults.size == 1) { if (grantResults.size == 1) {
when (requestCode) { when (requestCode) {
CAMERA_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { CAMERA_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
permissionsGranted = true permissionsGranted = true
setupCamera() setupCamera()
} else { } else {
CustomAlertDialogBuilder(this, themeValue) 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()
}.show()
} }
AUDIO_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { AUDIO_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
if (videoCapture != null) { if (videoCapture != null) {
@ -316,6 +350,18 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
} }
} }
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
val screenHeight = resources.displayMetrics.heightPixels val screenHeight = resources.displayMetrics.heightPixels
@ -335,52 +381,59 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
imageCapture = ImageCapture.Builder() imageCapture = ImageCapture.Builder()
.setCaptureMode(captureMode) .setCaptureMode(captureMode)
.setFlashMode(imageCapture?.flashMode ?: ImageCapture.FLASH_MODE_AUTO) .setFlashMode(imageCapture?.flashMode ?: ImageCapture.FLASH_MODE_AUTO)
.apply { .setResolutionSelector(ResolutionSelector.Builder().setResolutionFilter { supportedSizes, _ ->
currentResolution?.let { resolutions = supportedSizes.sortedBy {
setTargetResolution(it) -it.width*it.height
}
currentResolution?.let { targetResolution ->
return@setResolutionFilter supportedSizes.sortedBy {
sqrt((it.width - targetResolution.width).toDouble().pow(2) + (it.height - targetResolution.height).toDouble().pow(2))
} }
} }
supportedSizes
}.build())
.setTargetRotation(currentRotation)
.build() .build()
} }
private fun refreshVideoCapture() { private fun refreshVideoCapture() {
videoCapture = VideoCapture.Builder().apply { val recorderBuilder = SucklessRecorder.Builder()
currentResolution?.let { .setExecutor(executor)
setTargetResolution(it) .setAspectRatio(aspectRatios[currentAspectRatioIndex])
if (currentQualityIndex != -1) {
recorderBuilder.setQualitySelector(QualitySelector.from(qualities!![currentQualityIndex]))
}
videoRecorder = recorderBuilder.build()
videoCapture = VideoCapture.withOutput(videoRecorder!!).apply {
targetRotation = currentRotation
} }
}.build()
}
@SuppressLint("RestrictedApi")
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)
} }
private fun rebindUseCases(): UseCase {
cameraProvider.unbindAll() cameraProvider.unbindAll()
val currentUseCase = (if (isInVideoMode) { val currentUseCase = (if (isInVideoMode) {
refreshVideoCapture() refreshVideoCapture()
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture) camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture)
if (qualities == null) {
qualities = SucklessRecorder.getVideoCapabilities(camera!!.cameraInfo).getSupportedQualities(DynamicRange.UNSPECIFIED)
}
videoCapture videoCapture
} else { } else {
refreshImageCapture() refreshImageCapture()
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture) camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture)
imageCapture imageCapture
})!! })!!
adaptPreviewSize(currentUseCase.attachedSurfaceResolution!!.swap())
adaptPreviewSize(currentResolution ?: currentUseCase.attachedSurfaceResolution!!.swap()) return currentUseCase
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(currentUseCase.imageFormat).map { it.swap() }
} }
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()
} }
} }
@ -427,14 +480,10 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
if (encryptedVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), outputPath)) { if (encryptedVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), outputPath)) {
Toast.makeText(applicationContext, getString(R.string.picture_save_success, outputPath), Toast.LENGTH_SHORT).show() Toast.makeText(applicationContext, getString(R.string.picture_save_success, outputPath), Toast.LENGTH_SHORT).show()
} else { } else {
CustomAlertDialogBuilder(this@CameraActivity, themeValue) CustomAlertDialogBuilder(this@CameraActivity, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.picture_save_failed) .setMessage(R.string.picture_save_failed)
.setCancelable(false) .setPositiveButton(R.string.ok, null)
.setPositiveButton(R.string.ok) { _, _ ->
isFinishingIntentionally = true
finish()
}
.show() .show()
} }
} }
@ -451,67 +500,89 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
private fun onClickRecordVideo() { private fun onClickRecordVideo() {
if (isRecording) { if (isRecording) {
videoCapture?.stopRecording() videoRecording?.stop()
isRecording = false
} else if (!isWaitingForTimer) { } else if (!isWaitingForTimer) {
val path = getOutputPath(true) val path = getOutputPath(true)
startTimerThen { val fileHandle = encryptedVolume.openFileWriteMode(path)
val fileHandle = encryptedVolume.openFile(path) if (fileHandle == -1L) {
videoCapture?.startRecording(VideoCapture.OutputFileOptions(object : SeekableWriter { CustomAlertDialogBuilder(this, theme)
var offset = 0L .setTitle(R.string.error)
override fun write(byteArray: ByteArray) { .setMessage(R.string.file_creation_failed)
offset += encryptedVolume.write(fileHandle, offset, byteArray, 0, byteArray.size.toLong()) .setPositiveButton(R.string.ok, null)
} .show()
override fun seek(offset: Long) { return
this.offset = offset
} }
val writer = AsynchronousSeekableWriter(object : SeekableWriter {
private var offset = 0L
override fun close() { override fun close() {
encryptedVolume.closeFile(fileHandle) encryptedVolume.closeFile(fileHandle)
} }
}), executor, object : VideoCapture.OnVideoSavedCallback {
override fun onVideoSaved() { override fun seek(offset: Long) {
Toast.makeText(applicationContext, getString(R.string.video_save_success, path), Toast.LENGTH_SHORT).show() this.offset = offset
binding.recordVideoButton.setImageResource(R.drawable.record_video_button)
} }
override fun onError(videoCaptureError: Int, message: String, cause: Throwable?) {
Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() override fun write(buffer: ByteArray, size: Int) {
cause?.printStackTrace() offset += encryptedVolume.write(fileHandle, offset, buffer, 0, size.toLong())
binding.recordVideoButton.setImageResource(R.drawable.record_video_button)
} }
}) })
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) binding.recordVideoButton.setImageResource(R.drawable.stop_recording_video_button)
for (i in buttons) {
i.isEnabled = false
i.alpha = 0.5F
}
isRecording = true 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 onDestroy() {
super.onDestroy()
if (!isFinishingIntentionally) {
encryptedVolume.close()
RestrictedFileProvider.wipeAll(this)
} }
} }
override fun onStop() {
super.onStop()
if (!isFinishing && !usf_keep_open){
finish()
} }
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
sensorOrientationListener.remove(this) sensorOrientationListener.remove(this)
if (!isAskingPermissions && !usf_keep_open) {
finish()
}
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (encryptedVolume.isClosed()) {
finish()
} else {
sensorOrientationListener.addListener(this) sensorOrientationListener.addListener(this)
} }
}
override fun onOrientationChange(newOrientation: Int) { override fun onOrientationChange(newOrientation: Int) {
val realOrientation = when (newOrientation) { val realOrientation = when (newOrientation) {
@ -531,7 +602,8 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
orientedIcons.map { it.startAnimation(rotateAnimation) } orientedIcons.map { it.startAnimation(rotateAnimation) }
previousOrientation = realOrientation previousOrientation = realOrientation
imageCapture?.targetRotation = newOrientation imageCapture?.targetRotation = newOrientation
videoCapture?.setTargetRotation(newOrientation) videoCapture?.targetRotation = newOrientation
currentRotation = newOrientation
} }
} }

View File

@ -16,7 +16,7 @@ import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.IntentUtils import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.ObjRef import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.WidgetUtil import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.util.* import java.util.*
@ -42,7 +42,7 @@ class ChangePasswordActivity: BaseActivity() {
volumeDatabase = VolumeDatabase(this) volumeDatabase = VolumeDatabase(this)
usfFingerprint = sharedPrefs.getBoolean("usf_fingerprint", false) usfFingerprint = sharedPrefs.getBoolean("usf_fingerprint", false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
fingerprintProtector = FingerprintProtector.new(this, themeValue, volumeDatabase) fingerprintProtector = FingerprintProtector.new(this, theme, volumeDatabase)
if (fingerprintProtector != null && volume.encryptedHash != null) { if (fingerprintProtector != null && volume.encryptedHash != null) {
binding.fingerprintSwitchContainer.visibility = View.VISIBLE binding.fingerprintSwitchContainer.visibility = View.VISIBLE
} }
@ -50,7 +50,7 @@ class ChangePasswordActivity: BaseActivity() {
if (!usfFingerprint || fingerprintProtector == null) { if (!usfFingerprint || fingerprintProtector == null) {
binding.checkboxSavePassword.visibility = View.GONE binding.checkboxSavePassword.visibility = View.GONE
} }
if (sharedPrefs.getBoolean(ConstValues.PIN_PASSWORDS_KEY, false)) { if (sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false)) {
arrayOf(binding.editCurrentPassword, binding.editNewPassword, binding.editPasswordConfirm).forEach { arrayOf(binding.editCurrentPassword, binding.editNewPassword, binding.editPasswordConfirm).forEach {
it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
} }
@ -89,8 +89,8 @@ class ChangePasswordActivity: BaseActivity() {
} }
private fun changeVolumePassword() { private fun changeVolumePassword() {
val newPassword = WidgetUtil.encodeEditTextContent(binding.editNewPassword) val newPassword = UIUtils.encodeEditTextContent(binding.editNewPassword)
val newPasswordConfirm = WidgetUtil.encodeEditTextContent(binding.editPasswordConfirm) val newPasswordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
@SuppressLint("NewApi") @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()
@ -135,11 +135,11 @@ class ChangePasswordActivity: BaseActivity() {
null null
} }
val currentPassword = if (givenHash == null) { val currentPassword = if (givenHash == null) {
WidgetUtil.encodeEditTextContent(binding.editCurrentPassword) UIUtils.encodeEditTextContent(binding.editCurrentPassword)
} else { } else {
null null
} }
object : LoadingTask<Boolean>(this, themeValue, R.string.loading_msg_change_password) { object : LoadingTask<Boolean>(this, theme, R.string.loading_msg_change_password) {
override suspend fun doTask(): Boolean { override suspend fun doTask(): Boolean {
val success = if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) { val success = if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) {
GocryptfsVolume.changePassword( GocryptfsVolume.changePassword(
@ -160,7 +160,7 @@ class ChangePasswordActivity: BaseActivity() {
) )
} }
if (success) { if (success) {
if (volumeDatabase.isHashSaved(volume.name)) { if (volumeDatabase.isHashSaved(volume)) {
volumeDatabase.removeHash(volume) volumeDatabase.removeHash(volume)
} }
} }
@ -199,7 +199,7 @@ class ChangePasswordActivity: BaseActivity() {
finish() finish()
} }
} else { } else {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.change_password_failed) .setMessage(R.string.change_password_failed)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@ -207,4 +207,11 @@ class ChangePasswordActivity: BaseActivity() {
} }
} }
} }
override fun onStop() {
super.onStop()
binding.editCurrentPassword.text.clear()
binding.editNewPassword.text.clear()
binding.editPasswordConfirm.text.clear()
}
} }

View File

@ -1,48 +0,0 @@
package sushi.hardcore.droidfs
import android.net.Uri
import java.io.File
object ConstValues {
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 THEME_VALUE_KEY = "theme"
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"
private val FILE_EXTENSIONS = mapOf(
Pair("image", listOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "heic")),
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac")),
Pair("pdf", listOf("pdf")),
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 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,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

@ -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", "webp", "bmp", "heic")),
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
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

@ -22,7 +22,7 @@ import javax.crypto.spec.GCMParameterSpec
@RequiresApi(Build.VERSION_CODES.M) @RequiresApi(Build.VERSION_CODES.M)
class FingerprintProtector private constructor( class FingerprintProtector private constructor(
private val activity: FragmentActivity, private val activity: FragmentActivity,
private val themeValue: String, private val theme: Theme,
private val volumeDatabase: VolumeDatabase, private val volumeDatabase: VolumeDatabase,
) { ) {
@ -54,11 +54,11 @@ class FingerprintProtector private constructor(
fun new( fun new(
activity: FragmentActivity, activity: FragmentActivity,
themeValue: String, theme: Theme,
volumeDatabase: VolumeDatabase, volumeDatabase: VolumeDatabase,
): FingerprintProtector? { ): FingerprintProtector? {
return if (canAuthenticate(activity) == 0) return if (canAuthenticate(activity) == 0)
FingerprintProtector(activity, themeValue, volumeDatabase) FingerprintProtector(activity, theme, volumeDatabase)
else else
null null
} }
@ -98,7 +98,7 @@ class FingerprintProtector private constructor(
listener.onPasswordHashDecrypted(plainText) listener.onPasswordHashDecrypted(plainText)
} catch (e: AEADBadTagException) { } catch (e: AEADBadTagException) {
listener.onFailed(true) listener.onFailed(true)
CustomAlertDialogBuilder(activity, themeValue) CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.MAC_verification_failed) .setMessage(R.string.MAC_verification_failed)
.setPositiveButton(R.string.reset_hash_storage) { _, _ -> .setPositiveButton(R.string.reset_hash_storage) { _, _ ->
@ -112,7 +112,7 @@ class FingerprintProtector private constructor(
} }
} catch (e: IllegalBlockSizeException) { } catch (e: IllegalBlockSizeException) {
listener.onFailed(true) listener.onFailed(true)
CustomAlertDialogBuilder(activity, themeValue) CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.illegal_block_size_exception) .setTitle(R.string.illegal_block_size_exception)
.setMessage(R.string.illegal_block_size_exception_msg) .setMessage(R.string.illegal_block_size_exception_msg)
.setPositiveButton(R.string.reset_hash_storage) { _, _ -> .setPositiveButton(R.string.reset_hash_storage) { _, _ ->
@ -159,7 +159,7 @@ class FingerprintProtector private constructor(
keyStore.getKey(KEY_ALIAS, null) as SecretKey keyStore.getKey(KEY_ALIAS, null) as SecretKey
} catch (e: UnrecoverableKeyException) { } catch (e: UnrecoverableKeyException) {
listener.onFailed(true) listener.onFailed(true)
CustomAlertDialogBuilder(activity, themeValue) CustomAlertDialogBuilder(activity, theme)
.setTitle(activity.getString(R.string.unrecoverable_key_exception)) .setTitle(activity.getString(R.string.unrecoverable_key_exception))
.setMessage(activity.getString(R.string.unrecoverable_key_exception_msg, e.localizedMessage)) .setMessage(activity.getString(R.string.unrecoverable_key_exception_msg, e.localizedMessage))
.setPositiveButton(R.string.reset_hash_storage) { _, _ -> .setPositiveButton(R.string.reset_hash_storage) { _, _ ->
@ -196,7 +196,7 @@ class FingerprintProtector private constructor(
private fun alertKeyPermanentlyInvalidatedException() { private fun alertKeyPermanentlyInvalidatedException() {
listener.onFailed(true) listener.onFailed(true)
CustomAlertDialogBuilder(activity, themeValue) CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.key_permanently_invalidated_exception) .setTitle(R.string.key_permanently_invalidated_exception)
.setMessage(R.string.key_permanently_invalidated_exception_msg) .setMessage(R.string.key_permanently_invalidated_exception_msg)
.setPositiveButton(R.string.reset_hash_storage) { _, _ -> .setPositiveButton(R.string.reset_hash_storage) { _, _ ->

View File

@ -8,8 +8,8 @@ import kotlinx.coroutines.withContext
import sushi.hardcore.droidfs.databinding.DialogLoadingBinding import sushi.hardcore.droidfs.databinding.DialogLoadingBinding
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
abstract class LoadingTask<T>(val activity: FragmentActivity, themeValue: String, loadingMessageResId: Int) { abstract class LoadingTask<T>(val activity: FragmentActivity, theme: Theme, loadingMessageResId: Int) {
private val dialogLoading = CustomAlertDialogBuilder(activity, themeValue) private val dialogLoading = CustomAlertDialogBuilder(activity, theme)
.setView( .setView(
DialogLoadingBinding.inflate(activity.layoutInflater).apply { DialogLoadingBinding.inflate(activity.layoutInflater).apply {
textMessage.text = activity.getString(loadingMessageResId) textMessage.text = activity.getString(loadingMessageResId)

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

@ -17,36 +17,41 @@ import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.ConstValues.DEFAULT_VOLUME_KEY import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
import sushi.hardcore.droidfs.adapters.VolumeAdapter import sushi.hardcore.droidfs.adapters.VolumeAdapter
import sushi.hardcore.droidfs.add_volume.AddVolumeActivity import sushi.hardcore.droidfs.add_volume.AddVolumeActivity
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.databinding.ActivityMainBinding import sushi.hardcore.droidfs.databinding.ActivityMainBinding
import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerRouter import sushi.hardcore.droidfs.explorers.ExplorerRouter
import sushi.hardcore.droidfs.file_operations.FileOperationService import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.util.IntentUtils import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog import sushi.hardcore.droidfs.widgets.EditTextDialog
import java.io.File import java.io.File
class MainActivity : BaseActivity(), VolumeAdapter.Listener { class MainActivity : BaseActivity(), VolumeAdapter.Listener {
companion object {
private const val OPEN_DEFAULT_VOLUME = "openDefault"
}
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var volumeDatabase: VolumeDatabase private lateinit var volumeDatabase: VolumeDatabase
private lateinit var volumeManager: VolumeManager
private lateinit var volumeAdapter: VolumeAdapter private lateinit var volumeAdapter: VolumeAdapter
private lateinit var volumeOpener: VolumeOpener private lateinit var volumeOpener: VolumeOpener
private var usfKeepOpen: Boolean = false
private var addVolume = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private var addVolume = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if ((explorerRouter.pickMode || explorerRouter.dropMode) && result.resultCode != AddVolumeActivity.RESULT_USER_BACK) { if ((explorerRouter.pickMode || explorerRouter.dropMode) && result.resultCode != AddVolumeActivity.RESULT_USER_BACK) {
setResult(result.resultCode, result.data) // forward result setResult(result.resultCode, result.data) // forward result
finish() finish()
} }
} }
private var changePasswordPosition: Int? = null private var selectedVolumePosition: Int? = null
private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
changePasswordPosition?.let { unselect(it) } selectedVolumePosition?.let { unselect(it) }
} }
private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) if (uri != null)
@ -54,14 +59,13 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
} }
private lateinit var fileOperationService: FileOperationService private lateinit var fileOperationService: FileOperationService
private lateinit var explorerRouter: ExplorerRouter private lateinit var explorerRouter: ExplorerRouter
private var shouldCloseVolume = true // used when launched to pick file from another volume
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
if (sharedPrefs.getBoolean("applicationFirstOpening", true)) { if (sharedPrefs.getBoolean("applicationFirstOpening", true)) {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage(R.string.usf_home_warning_msg) .setMessage(R.string.usf_home_warning_msg)
.setCancelable(false) .setCancelable(false)
@ -80,10 +84,12 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
.show() .show()
} }
explorerRouter = ExplorerRouter(this, intent) explorerRouter = ExplorerRouter(this, intent)
volumeManager = (application as VolumeManagerApp).volumeManager
volumeDatabase = VolumeDatabase(this) volumeDatabase = VolumeDatabase(this)
volumeAdapter = VolumeAdapter( volumeAdapter = VolumeAdapter(
this, this,
volumeDatabase, volumeDatabase,
(application as VolumeManagerApp).volumeManager,
!explorerRouter.pickMode && !explorerRouter.dropMode, !explorerRouter.pickMode && !explorerRouter.dropMode,
!explorerRouter.dropMode, !explorerRouter.dropMode,
this, this,
@ -100,30 +106,32 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
addVolume.launch(Intent(this, AddVolumeActivity::class.java).also { addVolume.launch(Intent(this, AddVolumeActivity::class.java).also {
if (explorerRouter.dropMode || explorerRouter.pickMode) { if (explorerRouter.dropMode || explorerRouter.pickMode) {
IntentUtils.forwardIntent(intent, it) IntentUtils.forwardIntent(intent, it)
shouldCloseVolume = false
} }
}) })
} }
usfKeepOpen = sharedPrefs.getBoolean("usf_keep_open", false)
volumeOpener = VolumeOpener(this) volumeOpener = VolumeOpener(this)
volumeOpener.defaultVolumeName?.let { name ->
try {
openVolume(volumeAdapter.volumes.first { it.name == name })
} catch (e: NoSuchElementException) {
unsetDefaultVolume()
}
}
onBackPressedDispatcher.addCallback(this) { onBackPressedDispatcher.addCallback(this) {
if (volumeAdapter.selectedItems.isNotEmpty()) { if (volumeAdapter.selectedItems.isNotEmpty()) {
unselectAll() unselectAll()
} else { } else {
if (explorerRouter.pickMode) {
shouldCloseVolume = false
}
isEnabled = false isEnabled = false
onBackPressedDispatcher.onBackPressed() onBackPressedDispatcher.onBackPressed()
} }
} }
volumeOpener.defaultVolumeName?.let { name ->
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()
}
}
}
startService(Intent(this, WiperService::class.java))
Intent(this, FileOperationService::class.java).also { Intent(this, FileOperationService::class.java).also {
bindService(it, object : ServiceConnection { bindService(it, object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) { override fun onServiceConnected(className: ComponentName, service: IBinder) {
@ -136,17 +144,25 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
// refresh theme if changed in SettingsActivity // check if theme was changed (by SettingsActivity)
val newThemeValue = sharedPrefs.getString(ConstValues.THEME_VALUE_KEY, ConstValues.DEFAULT_THEME_VALUE)!! val newTheme = Theme.fromSharedPrefs(sharedPrefs)
onThemeChanged(newThemeValue) if (newTheme != theme) {
volumeOpener.themeValue = newThemeValue recreate()
} else {
volumeAdapter.refresh() volumeAdapter.refresh()
invalidateOptionsMenu()
if (volumeAdapter.volumes.isNotEmpty()) { if (volumeAdapter.volumes.isNotEmpty()) {
binding.textNoVolumes.visibility = View.GONE binding.textNoVolumes.visibility = View.GONE
} }
// refresh this in case another instance of MainActivity changes its value // refresh this in case another instance of MainActivity changes its value
volumeOpener.defaultVolumeName = sharedPrefs.getString(DEFAULT_VOLUME_KEY, null) 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) { override fun onSelectionChanged(size: Int) {
title = if (size == 0) { title = if (size == 0) {
@ -179,6 +195,11 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
invalidateOptionsMenu() 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) { private fun removeVolumes(volumes: List<VolumeData>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
if (i < volumes.size) { if (i < volumes.size) {
if (volumes[i].isHidden) { if (volumes[i].isHidden) {
@ -192,16 +213,16 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
break break
} }
} }
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setView(dialogBinding.root) .setView(dialogBinding.root)
.setPositiveButton(R.string.forget_only) { _, _ -> .setPositiveButton(R.string.forget_only) { _, _ ->
volumeDatabase.removeVolume(volumes[i].name) removeVolume(volumes[i])
removeVolumes(volumes, i + 1, if (dialogBinding.checkboxApplyToAll.isChecked) false else null) removeVolumes(volumes, i + 1, if (dialogBinding.checkboxApplyToAll.isChecked) false else null)
} }
.setNegativeButton(R.string.delete_volume) { _, _ -> .setNegativeButton(R.string.delete_volume) { _, _ ->
PathUtils.recursiveRemoveDirectory(File(volumes[i].getFullPath(filesDir.path))) PathUtils.recursiveRemoveDirectory(File(volumes[i].getFullPath(filesDir.path)))
volumeDatabase.removeVolume(volumes[i].name) removeVolume(volumes[i])
removeVolumes(volumes, i + 1, if (dialogBinding.checkboxApplyToAll.isChecked) true else null) removeVolumes(volumes, i + 1, if (dialogBinding.checkboxApplyToAll.isChecked) true else null)
} }
.setOnCancelListener { .setOnCancelListener {
@ -213,11 +234,11 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
if (doDeleteVolumeContent) { if (doDeleteVolumeContent) {
PathUtils.recursiveRemoveDirectory(File(volumes[i].getFullPath(filesDir.path))) PathUtils.recursiveRemoveDirectory(File(volumes[i].getFullPath(filesDir.path)))
} }
volumeDatabase.removeVolume(volumes[i].name) removeVolume(volumes[i])
removeVolumes(volumes, i + 1, doDeleteVolumeContent) removeVolumes(volumes, i + 1, doDeleteVolumeContent)
} }
} else { } else {
volumeDatabase.removeVolume(volumes[i].name) removeVolume(volumes[i])
removeVolumes(volumes, i + 1, doDeleteVolumeContent) removeVolumes(volumes, i + 1, doDeleteVolumeContent)
} }
} else { } else {
@ -241,9 +262,6 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
return when (item.itemId) { return when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
if (explorerRouter.pickMode || explorerRouter.dropMode) { if (explorerRouter.pickMode || explorerRouter.dropMode) {
if (explorerRouter.pickMode) {
shouldCloseVolume = false
}
finish() finish()
} else { } else {
unselectAll() unselectAll()
@ -255,6 +273,15 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
invalidateOptionsMenu() invalidateOptionsMenu()
true true
} }
R.id.lock -> {
volumeAdapter.selectedItems.forEach {
volumeManager.getVolumeId(volumeAdapter.volumes[it])?.let { id ->
volumeManager.closeVolume(id)
}
}
unselectAll()
true
}
R.id.remove -> { R.id.remove -> {
val selectedVolumes = volumeAdapter.selectedItems.map { i -> volumeAdapter.volumes[i] } val selectedVolumes = volumeAdapter.selectedItems.map { i -> volumeAdapter.volumes[i] }
removeVolumes(selectedVolumes) removeVolumes(selectedVolumes)
@ -269,9 +296,9 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
true true
} }
R.id.change_password -> { R.id.change_password -> {
changePasswordPosition = volumeAdapter.selectedItems.elementAt(0) selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
changePassword.launch(Intent(this, ChangePasswordActivity::class.java).apply { changePassword.launch(Intent(this, ChangePasswordActivity::class.java).apply {
putExtra("volume", volumeAdapter.volumes[changePasswordPosition!!]) putExtra("volume", volumeAdapter.volumes[selectedVolumePosition!!])
}) })
true true
} }
@ -281,26 +308,32 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
true true
} }
R.id.copy -> { R.id.copy -> {
val position = volumeAdapter.selectedItems.elementAt(0) selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
val volume = volumeAdapter.volumes[position] val volume = volumeAdapter.volumes[selectedVolumePosition!!]
when { if (volume.isHidden) {
volume.isHidden -> { PathUtils.safePickDirectory(pickDirectory, this, theme)
PathUtils.safePickDirectory(pickDirectory, this, themeValue) } else {
} val hiddenVolumeFile = File(VolumeData.getHiddenVolumeFullPath(filesDir.path, volume.shortName))
File(filesDir, volume.shortName).exists() -> { if (hiddenVolumeFile.exists()) {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.hidden_volume_already_exists) .setMessage(R.string.hidden_volume_already_exists)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} } else {
else -> { unselect(selectedVolumePosition!!)
unselect(position)
copyVolume( copyVolume(
DocumentFile.fromFile(File(volume.name)), DocumentFile.fromFile(File(volume.name)),
DocumentFile.fromFile(filesDir), DocumentFile.fromFile(hiddenVolumeFile.parentFile!!),
) { ) {
VolumeData(volume.shortName, true, volume.type, volume.encryptedHash, volume.iv) VolumeData(
VolumeData.newUuid(),
volume.shortName,
true,
volume.type,
volume.encryptedHash,
volume.iv
)
} }
} }
} }
@ -322,9 +355,16 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
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)
menu.findItem(R.id.settings).isVisible = !explorerRouter.pickMode && !explorerRouter.dropMode 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() val isSelecting = volumeAdapter.selectedItems.isNotEmpty()
menu.findItem(R.id.select_all).isVisible = isSelecting 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.remove).isVisible = isSelecting
menu.findItem(R.id.delete_password_hash).isVisible = menu.findItem(R.id.delete_password_hash).isVisible =
isSelecting && isSelecting &&
@ -347,18 +387,17 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
) )
} }
} }
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable && !volumeManager.isOpen(volumeAdapter.volumes[volumeAdapter.selectedItems.first()])
supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode) supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode)
return true return true
} }
private fun onDirectoryPicked(uri: Uri) { private fun onDirectoryPicked(uri: Uri) {
val position = volumeAdapter.selectedItems.elementAt(0) val volume = volumeAdapter.volumes[selectedVolumePosition!!]
val volume = volumeAdapter.volumes[position] unselect(selectedVolumePosition!!)
unselect(position)
val dstDocumentFile = DocumentFile.fromTreeUri(this, uri) val dstDocumentFile = DocumentFile.fromTreeUri(this, uri)
if (dstDocumentFile == null) { if (dstDocumentFile == null) {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.path_error) .setMessage(R.string.path_error)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@ -372,6 +411,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this) val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this)
if (path == null) null if (path == null) null
else VolumeData( else VolumeData(
VolumeData.newUuid(),
PathUtils.pathJoin(path, name), PathUtils.pathJoin(path, name),
false, false,
volume.type, volume.type,
@ -383,14 +423,21 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
} }
} }
/**
* 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?) { private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> VolumeData?) {
lifecycleScope.launch { lifecycleScope.launch {
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile) val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
when { when (result.taskResult.state) {
result.taskResult.cancelled -> { TaskResult.State.CANCELLED -> {
result.dstRootDirectory?.delete() result.dstRootDirectory?.delete()
} }
result.taskResult.failedItem == null -> { TaskResult.State.SUCCESS -> {
result.dstRootDirectory?.let { result.dstRootDirectory?.let {
getResultVolume(it)?.let { volume -> getResultVolume(it)?.let { volume ->
volumeDatabase.saveVolume(volume) volumeDatabase.saveVolume(volume)
@ -403,13 +450,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
} }
} }
} }
else -> { TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this@MainActivity, themeValue) CustomAlertDialogBuilder(this@MainActivity, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem.name)) .setMessage(getString(R.string.copy_failed, result.taskResult.failedItem!!.name))
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} }
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
} }
} }
} }
@ -432,7 +480,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
DocumentFile.fromFile(srcPath).renameTo(newName) DocumentFile.fromFile(srcPath).renameTo(newName)
} }
if (success) { if (success) {
volumeDatabase.renameVolume(volume.name, newDBName) volumeDatabase.renameVolume(volume, newDBName)
VolumeProvider.notifyRootsChanged(this)
unselect(position) unselect(position)
if (volume.name == volumeOpener.defaultVolumeName) { if (volume.name == volumeOpener.defaultVolumeName) {
with (sharedPrefs.edit()) { with (sharedPrefs.edit()) {
@ -456,11 +505,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
volumeAdapter.refresh() volumeAdapter.refresh()
} }
override fun onVolumeOpened(encryptedVolume: EncryptedVolume, volumeShortName: String) { override fun onVolumeOpened(id: Int) {
startActivity(explorerRouter.getExplorerIntent(encryptedVolume, volumeShortName)) startActivity(explorerRouter.getExplorerIntent(id, volume.shortName))
if (explorerRouter.pickMode) {
shouldCloseVolume = false
}
if (explorerRouter.dropMode || explorerRouter.pickMode) { if (explorerRouter.dropMode || explorerRouter.pickMode) {
finish() finish()
} }
@ -468,17 +514,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}) })
} }
override fun onResume() {
super.onResume()
shouldCloseVolume = true
}
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (explorerRouter.pickMode && !usfKeepOpen && shouldCloseVolume) { volumeOpener.wipeSensitive()
IntentUtils.getParcelableExtra<EncryptedVolume>(intent, "volume")?.close()
RestrictedFileProvider.wipeAll(this)
finish()
}
} }
} }

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

@ -1,13 +1,22 @@
package sushi.hardcore.droidfs package sushi.hardcore.droidfs
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
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.view.MenuItem import android.view.MenuItem
import android.widget.Toast import android.widget.Toast
import androidx.preference.* import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager
import androidx.preference.SwitchPreference
import androidx.preference.SwitchPreferenceCompat
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.databinding.ActivitySettingsBinding import sushi.hardcore.droidfs.databinding.ActivitySettingsBinding
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -60,7 +69,7 @@ class SettingsActivity : BaseActivity() {
showMaxSizeDialog() showMaxSizeDialog()
} else { } else {
with(sharedPrefs.edit()) { with(sharedPrefs.edit()) {
putLong(ConstValues.THUMBNAIL_MAX_SIZE_KEY, value) putLong(Constants.THUMBNAIL_MAX_SIZE_KEY, value)
apply() apply()
} }
maxSizePreference.summary = PathUtils.formatSize(size) maxSizePreference.summary = PathUtils.formatSize(size)
@ -79,19 +88,31 @@ class SettingsActivity : BaseActivity() {
} }
} }
private fun refreshTheme() {
with(requireActivity()) {
startActivity(Intent(this, SettingsActivity::class.java))
finish()
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
}
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)
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
findPreference<ListPreference>(ConstValues.THEME_VALUE_KEY)?.setOnPreferenceChangeListener { _, newValue -> findPreference<ListPreference>("color")?.setOnPreferenceChangeListener { _, _ ->
(activity as BaseActivity).onThemeChanged(newValue as String) refreshTheme()
true true
} }
findPreference<Preference>(ConstValues.THUMBNAIL_MAX_SIZE_KEY)?.let { findPreference<SwitchPreferenceCompat>("black_theme")?.setOnPreferenceChangeListener { _, _ ->
refreshTheme()
true
}
findPreference<Preference>(Constants.THUMBNAIL_MAX_SIZE_KEY)?.let {
maxSizePreference = it maxSizePreference = it
maxSizePreference.summary = getString( maxSizePreference.summary = getString(
R.string.thumbnail_max_size_summary, R.string.thumbnail_max_size_summary,
PathUtils.formatSize(sharedPrefs.getLong( PathUtils.formatSize(sharedPrefs.getLong(
ConstValues.THUMBNAIL_MAX_SIZE_KEY, ConstValues.DEFAULT_THUMBNAIL_MAX_SIZE Constants.THUMBNAIL_MAX_SIZE_KEY, Constants.DEFAULT_THUMBNAIL_MAX_SIZE
)*1000) )*1000)
) )
maxSizePreference.setOnPreferenceClickListener { maxSizePreference.setOnPreferenceClickListener {
@ -99,6 +120,10 @@ class SettingsActivity : BaseActivity() {
false false
} }
} }
findPreference<Preference>("logcat")?.setOnPreferenceClickListener { _ ->
startActivity(Intent(requireContext(), LogcatActivity::class.java))
true
}
} }
} }
@ -126,7 +151,7 @@ class SettingsActivity : BaseActivity() {
if (errorMsg == null) { if (errorMsg == null) {
true true
} else { } else {
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).themeValue) CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(errorMsg) .setMessage(errorMsg)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@ -137,6 +162,50 @@ class SettingsActivity : BaseActivity() {
true true
} }
} }
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 updateView(usfOpen: Boolean? = null, usfKeepOpen: Boolean? = null, usfExpose: Boolean? = null) {
val usfKeepOpen = usfKeepOpen ?: switchKeepOpen.isChecked
switchExpose.isEnabled = usfKeepOpen
switchSafWrite.isEnabled = usfOpen ?: switchExternalOpen.isChecked || (usfKeepOpen && usfExpose ?: switchExpose.isChecked)
}
updateView()
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
updateView(usfKeepOpen = checked as Boolean)
true
}
switchExternalOpen.setOnPreferenceChangeListener { _, checked ->
updateView(usfOpen = checked as Boolean)
true
}
switchExpose.setOnPreferenceChangeListener { _, checked ->
VolumeProvider.usfExpose = checked as Boolean
updateView(usfExpose = checked)
VolumeProvider.notifyRootsChanged(requireContext())
true
}
switchSafWrite.setOnPreferenceChangeListener { _, checked ->
VolumeProvider.usfSafWrite = checked as Boolean
TemporaryFileProvider.usfSafWrite = checked
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

@ -2,12 +2,25 @@ package sushi.hardcore.droidfs
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable 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 sushi.hardcore.droidfs.util.PathUtils
import java.io.File import java.io.File
import java.io.FileInputStream
import java.util.UUID
class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte, var encryptedHash: ByteArray? = null, var iv: ByteArray? = null): Parcelable { 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( constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readString()!!, parcel.readString()!!,
parcel.readByte() != 0.toByte(), parcel.readByte() != 0.toByte(),
parcel.readByte(), parcel.readByte(),
@ -19,11 +32,28 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
File(name).name File(name).name
} }
fun getFullPath(filesDir: String): String { fun getFullPath(filesDir: String) = getFullPath(name, isHidden, filesDir)
return if (isHidden)
getHiddenVolumeFullPath(filesDir, name) fun canRead(filesDir: String): Boolean {
else val volumePath = getFullPath(filesDir)
name 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 { fun canWrite(filesDir: String): Boolean {
@ -36,6 +66,7 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
override fun writeToParcel(dest: Parcel, flags: Int) { override fun writeToParcel(dest: Parcel, flags: Int) {
with (dest) { with (dest) {
writeString(uuid)
writeString(name) writeString(name)
writeByte(if (isHidden) 1 else 0) writeByte(if (isHidden) 1 else 0)
writeByte(type) writeByte(type)
@ -44,6 +75,15 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
} }
} }
override fun equals(other: Any?): Boolean {
if (other !is VolumeData) {
return false
}
return other.uuid == uuid
}
override fun hashCode() = uuid.hashCode()
companion object { companion object {
const val VOLUMES_DIRECTORY = "volumes" const val VOLUMES_DIRECTORY = "volumes"
@ -53,8 +93,17 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size) override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size)
} }
fun newUuid(): String = UUID.randomUUID().toString()
fun getHiddenVolumeFullPath(filesDir: String, name: String): String { fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name) 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

@ -10,46 +10,81 @@ import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import java.io.File import java.io.File
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, ConstValues.VOLUME_DATABASE_NAME, null, 4) { class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 6) {
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_TYPE = "type" private const val COLUMN_NAME = "name"
const val COLUMN_HASH = "hash" private const val COLUMN_HIDDEN = "hidden"
const val COLUMN_IV = "iv" private const val COLUMN_TYPE = "type"
private const val COLUMN_HASH = "hash"
private const val COLUMN_IV = "iv"
}
private fun contentValuesFromVolume(volume: VolumeData): ContentValues { private fun createTable(db: SQLiteDatabase) =
val contentValues = ContentValues()
contentValues.put(COLUMN_NAME, volume.name)
contentValues.put(COLUMN_HIDDEN, volume.isHidden)
contentValues.put(COLUMN_TYPE, byteArrayOf(volume.type))
contentValues.put(COLUMN_HASH, volume.encryptedHash)
contentValues.put(COLUMN_IV, volume.iv)
return contentValues
}
}
override fun onCreate(db: SQLiteDatabase) {
db.execSQL( db.execSQL(
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" + "CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
"$COLUMN_NAME TEXT PRIMARY KEY," + "$COLUMN_UUID TEXT PRIMARY KEY," +
"$COLUMN_NAME TEXT," +
"$COLUMN_HIDDEN SHORT," + "$COLUMN_HIDDEN SHORT," +
"$COLUMN_TYPE BLOB," + "$COLUMN_TYPE BLOB," +
"$COLUMN_HASH BLOB," + "$COLUMN_HASH BLOB," +
"$COLUMN_IV BLOB" + "$COLUMN_IV BLOB" +
");" ");"
) )
override fun onCreate(db: SQLiteDatabase) {
createTable(db)
File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir() 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) { 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 // 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.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $COLUMN_TYPE BLOB;")
db.update(TABLE_NAME, ContentValues().apply { db.update(TABLE_NAME, ContentValues().apply {
put(COLUMN_TYPE, byteArrayOf(EncryptedVolume.GOCRYPTFS_VOLUME_TYPE)) put(COLUMN_TYPE, byteArrayOf(EncryptedVolume.GOCRYPTFS_VOLUME_TYPE))
}, null, null) }, null, null)
// Moving hidden volumes to the "volumes" directory // Moving registered hidden volumes to the "volumes" directory
if (File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()) { if (File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()) {
val cursor = db.query( val cursor = db.query(
TABLE_NAME, TABLE_NAME,
@ -62,29 +97,64 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
) )
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val volumeName = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)) val volumeName = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME))
File( val success = File(
PathUtils.pathJoin( PathUtils.pathJoin(
context.filesDir.path, context.filesDir.path,
volumeName volumeName
) )
).renameTo( ).renameTo(getNewVolumePath(volumeName))
File( if (!success) {
VolumeData( Log.e(TAG, "Failed to move $volumeName")
volumeName, }
true,
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE
).getFullPath(context.filesDir.path)
).canonicalFile
)
} }
cursor.close() cursor.close()
} else { } else {
Log.e("VolumeDatabase", "Volumes directory creation failed while upgrading") 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;")
}
} }
} }
private fun extractVolumeData(cursor: Cursor): VolumeData { private fun extractVolumeData(cursor: Cursor): VolumeData {
return VolumeData( return VolumeData(
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)),
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)), cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)),
cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(), cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(),
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE))[0], cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE))[0],
@ -122,7 +192,14 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
fun saveVolume(volume: VolumeData): Boolean { fun saveVolume(volume: VolumeData): Boolean {
if (!isVolumeSaved(volume.name, volume.isHidden)) { 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
} }
@ -137,8 +214,8 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
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.getColumnIndexOrThrow(COLUMN_HASH)) != null) { if (cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)) != null) {
@ -150,32 +227,33 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
} }
fun addHash(volume: VolumeData): 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: VolumeData): Boolean { fun removeHash(volume: VolumeData): Boolean {
return writableDatabase.update( return writableDatabase.update(
TABLE_NAME, contentValuesFromVolume( TABLE_NAME,
VolumeData(
volume.name,
volume.isHidden,
volume.type,
null,
null
)
), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
}
fun renameVolume(oldName: String, newName: String): Boolean {
return writableDatabase.update(TABLE_NAME,
ContentValues().apply { ContentValues().apply {
put(COLUMN_NAME, newName) put(COLUMN_HASH, null as ByteArray?)
}, put(COLUMN_IV, null as ByteArray?)
"$COLUMN_NAME=?",arrayOf(oldName) }, "$COLUMN_UUID=?", arrayOf(volume.uuid)
) > 0 ) > 0
} }
fun removeVolume(volumeName: String): Boolean { fun renameVolume(volume: VolumeData, newName: String): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_NAME=?", arrayOf(volumeName)) > 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,64 @@
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
class VolumeManager(private val context: Context) {
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
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 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.close()
volumesData.filter { it.value == id }.forEach {
volumesData.remove(it.key)
}
VolumeProvider.notifyRootsChanged(context)
}
}
fun closeAll() {
volumes.forEach {
scopes[it.key]?.cancel()
it.value.close()
}
volumes.clear()
volumesData.clear()
VolumeProvider.notifyRootsChanged(context)
}
}

View File

@ -0,0 +1,54 @@
package sushi.hardcore.droidfs
import android.app.Application
import android.content.SharedPreferences
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
class VolumeManagerApp : Application(), DefaultLifecycleObserver {
companion object {
private const val USF_KEEP_OPEN_KEY = "usf_keep_open"
}
lateinit var sharedPreferences: SharedPreferences
private val sharedPreferencesListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == USF_KEEP_OPEN_KEY) {
reloadUsfKeepOpen()
}
}
private var usfKeepOpen = false
var isExporting = false
var isStartingExternalApp = false
val volumeManager = VolumeManager(this)
override fun onCreate() {
super<Application>.onCreate()
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this).apply {
registerOnSharedPreferenceChangeListener(sharedPreferencesListener)
}
reloadUsfKeepOpen()
}
private fun reloadUsfKeepOpen() {
usfKeepOpen = sharedPreferences.getBoolean(USF_KEEP_OPEN_KEY, false)
}
override fun onResume(owner: LifecycleOwner) {
isStartingExternalApp = false
}
override fun onStop(owner: LifecycleOwner) {
if (!isStartingExternalApp) {
if (!usfKeepOpen) {
volumeManager.closeAll()
}
if (!usfKeepOpen || !isExporting) {
TemporaryFileProvider.instance.wipe()
}
}
}
}

View File

@ -9,11 +9,11 @@ import android.widget.Toast
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.ConstValues.DEFAULT_VOLUME_KEY import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.ObjRef import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.WidgetUtil import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.util.* import java.util.*
@ -22,23 +22,35 @@ class VolumeOpener(
) { ) {
interface VolumeOpenerCallbacks { interface VolumeOpenerCallbacks {
fun onHashStorageReset() {} fun onHashStorageReset() {}
fun onVolumeOpened(encryptedVolume: EncryptedVolume, volumeShortName: String) fun onVolumeOpened(id: Int)
} }
private val volumeDatabase = VolumeDatabase(activity) private val volumeDatabase = VolumeDatabase(activity)
private var fingerprintProtector: FingerprintProtector? = null private var fingerprintProtector: FingerprintProtector? = null
private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(activity) private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(activity)
var themeValue = sharedPrefs.getString(ConstValues.THEME_VALUE_KEY, ConstValues.DEFAULT_THEME_VALUE)!! private val theme = (activity as BaseActivity).theme
var defaultVolumeName: String? = sharedPrefs.getString(DEFAULT_VOLUME_KEY, null) var defaultVolumeName: String? = sharedPrefs.getString(DEFAULT_VOLUME_KEY, null)
private var dialogBinding: DialogOpenVolumeBinding? = null
private val volumeManager = (activity.application as VolumeManagerApp).volumeManager
init { init {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
fingerprintProtector = FingerprintProtector.new(activity, themeValue, volumeDatabase) 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 @SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23
fun openVolume(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks) { 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) { if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE && BuildConfig.GOCRYPTFS_DISABLED) {
Toast.makeText(activity, R.string.gocryptfs_disabled, Toast.LENGTH_SHORT).show() Toast.makeText(activity, R.string.gocryptfs_disabled, Toast.LENGTH_SHORT).show()
return return
@ -56,27 +68,28 @@ class VolumeOpener(
callbacks.onHashStorageReset() callbacks.onHashStorageReset()
} }
override fun onPasswordHashDecrypted(hash: ByteArray) { override fun onPasswordHashDecrypted(hash: ByteArray) {
object : LoadingTask<EncryptedVolume?>(activity, themeValue, R.string.loading_msg_open) { object : LoadingTask<EncryptedVolume.InitResult>(activity, theme, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume? { override suspend fun doTask(): EncryptedVolume.InitResult {
val encryptedVolume = EncryptedVolume.init(volume, activity.filesDir.path, null, hash, null) val result = EncryptedVolume.init(volume, activity.filesDir.path, null, hash, null)
Arrays.fill(hash, 0) Arrays.fill(hash, 0)
return encryptedVolume return result
} }
}.startTask(activity.lifecycleScope) { encryptedVolume -> }.startTask(activity.lifecycleScope) { result ->
val encryptedVolume = result.volume
if (encryptedVolume == null) { if (encryptedVolume == null) {
CustomAlertDialogBuilder(activity, themeValue) CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.open_volume_failed) .setTitle(R.string.open_volume_failed)
.setMessage(R.string.open_failed_hash_msg) .setMessage(getErrorMsg(result))
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} else { } else {
callbacks.onVolumeOpened(encryptedVolume, volume.shortName) callbacks.onVolumeOpened(volumeManager.insert(encryptedVolume, volume))
} }
} }
} }
override fun onPasswordHashSaved() {} override fun onPasswordHashSaved() {}
override fun onFailed(pending: Boolean) { override fun onFailed(pending: Boolean) {
if (!pending) { if (!pending && sharedPrefs.getBoolean("passwordFallback", true)) {
askForPassword(volume, isVolumeSaved, callbacks) askForPassword(volume, isVolumeSaved, callbacks)
} }
} }
@ -88,12 +101,19 @@ class VolumeOpener(
if (askForPassword) { if (askForPassword) {
askForPassword(volume, isVolumeSaved, callbacks) askForPassword(volume, isVolumeSaved, callbacks)
} }
} else {
callbacks.onVolumeOpened(volumeId)
}
} }
private fun onPasswordSubmitted(volume: VolumeData, isVolumeSaved: Boolean, dialogBinding: DialogOpenVolumeBinding, callbacks: VolumeOpenerCallbacks) { fun wipeSensitive() {
if (dialogBinding.checkboxDefaultOpen.isChecked xor (defaultVolumeName == volume.name)) { dialogBinding?.editPassword?.text?.clear()
}
private fun onPasswordSubmitted(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks) {
if (dialogBinding!!.checkboxDefaultOpen.isChecked xor (defaultVolumeName == volume.name)) {
with (sharedPrefs.edit()) { with (sharedPrefs.edit()) {
defaultVolumeName = if (dialogBinding.checkboxDefaultOpen.isChecked) { defaultVolumeName = if (dialogBinding!!.checkboxDefaultOpen.isChecked) {
putString(DEFAULT_VOLUME_KEY, volume.name) putString(DEFAULT_VOLUME_KEY, volume.name)
volume.name volume.name
} else { } else {
@ -103,44 +123,47 @@ class VolumeOpener(
apply() apply()
} }
} }
val password = UIUtils.encodeEditTextContent(dialogBinding!!.editPassword)
val savePasswordHash = dialogBinding!!.checkboxSavePassword.isChecked
dialogBinding = null
// openVolumeWithPassword is responsible for wiping the password // openVolumeWithPassword is responsible for wiping the password
openVolumeWithPassword( openVolumeWithPassword(
volume, volume,
WidgetUtil.encodeEditTextContent(dialogBinding.editPassword), password,
isVolumeSaved, isVolumeSaved,
dialogBinding.checkboxSavePassword.isChecked, savePasswordHash,
callbacks, callbacks,
) )
} }
private fun askForPassword(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks, savePasswordHash: Boolean = false) { private fun askForPassword(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks, savePasswordHash: Boolean = false) {
val dialogBinding = DialogOpenVolumeBinding.inflate(activity.layoutInflater) dialogBinding = DialogOpenVolumeBinding.inflate(activity.layoutInflater)
if (isVolumeSaved) { if (isVolumeSaved) {
if (!sharedPrefs.getBoolean("usf_fingerprint", false) || fingerprintProtector == null || volume.encryptedHash != null) { if (!sharedPrefs.getBoolean("usf_fingerprint", false) || fingerprintProtector == null || volume.encryptedHash != null) {
dialogBinding.checkboxSavePassword.visibility = View.GONE dialogBinding!!.checkboxSavePassword.visibility = View.GONE
} else { } else {
dialogBinding.checkboxSavePassword.isChecked = savePasswordHash dialogBinding!!.checkboxSavePassword.isChecked = savePasswordHash
} }
dialogBinding.checkboxDefaultOpen.isChecked = defaultVolumeName == volume.name dialogBinding!!.checkboxDefaultOpen.isChecked = defaultVolumeName == volume.name
} else { } else {
dialogBinding.checkboxSavePassword.visibility = View.GONE dialogBinding!!.checkboxSavePassword.visibility = View.GONE
dialogBinding.checkboxDefaultOpen.visibility = View.GONE dialogBinding!!.checkboxDefaultOpen.visibility = View.GONE
} }
val dialog = CustomAlertDialogBuilder(activity, themeValue) val dialog = CustomAlertDialogBuilder(activity, theme)
.setTitle(activity.getString(R.string.open_dialog_title, volume.shortName)) .setTitle(activity.getString(R.string.open_dialog_title, volume.shortName))
.setView(dialogBinding.root) .setView(dialogBinding!!.root)
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.open) { _, _ -> .setPositiveButton(R.string.open) { _, _ ->
onPasswordSubmitted(volume, isVolumeSaved, dialogBinding, callbacks) onPasswordSubmitted(volume, isVolumeSaved, callbacks)
} }
.create() .create()
dialogBinding.editPassword.apply { dialogBinding!!.editPassword.apply {
setOnEditorActionListener { _, _, _ -> setOnEditorActionListener { _, _, _ ->
dialog.dismiss() dialog.dismiss()
onPasswordSubmitted(volume, isVolumeSaved, dialogBinding, callbacks) onPasswordSubmitted(volume, isVolumeSaved, callbacks)
true true
} }
if (sharedPrefs.getBoolean(ConstValues.PIN_PASSWORDS_KEY, false)) { if (sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false)) {
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
} }
} }
@ -154,20 +177,23 @@ class VolumeOpener(
} else { } else {
null null
} }
object : LoadingTask<EncryptedVolume?>(activity, themeValue, R.string.loading_msg_open) { object : LoadingTask<EncryptedVolume.InitResult>(activity, theme, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume? { override suspend fun doTask(): EncryptedVolume.InitResult {
val encryptedVolume = EncryptedVolume.init(volume, activity.filesDir.path, password, null, returnedHash) val result = EncryptedVolume.init(volume, activity.filesDir.path, password, null, returnedHash)
Arrays.fill(password, 0) Arrays.fill(password, 0)
return encryptedVolume return result
} }
}.startTask(activity.lifecycleScope) { encryptedVolume -> }.startTask(activity.lifecycleScope) { result ->
val encryptedVolume = result.volume
if (encryptedVolume == null) { if (encryptedVolume == null) {
CustomAlertDialogBuilder(activity, themeValue) CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.open_volume_failed) .setTitle(R.string.open_volume_failed)
.setMessage(R.string.open_volume_failed_msg) .setMessage(getErrorMsg(result))
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ ->
if (result.worthRetry) {
askForPassword(volume, isVolumeSaved, callbacks, savePasswordHash) askForPassword(volume, isVolumeSaved, callbacks, savePasswordHash)
} }
}
.show() .show()
} else { } else {
val fingerprintProtector = fingerprintProtector val fingerprintProtector = fingerprintProtector
@ -180,7 +206,7 @@ class VolumeOpener(
override fun onPasswordHashDecrypted(hash: ByteArray) {} override fun onPasswordHashDecrypted(hash: ByteArray) {}
override fun onPasswordHashSaved() { override fun onPasswordHashSaved() {
Arrays.fill(returnedHash.value!!, 0) Arrays.fill(returnedHash.value!!, 0)
callbacks.onVolumeOpened(encryptedVolume, volume.shortName) callbacks.onVolumeOpened(volumeManager.insert(encryptedVolume, volume))
} }
private var isClosed = false private var isClosed = false
override fun onFailed(pending: Boolean) { override fun onFailed(pending: Boolean) {
@ -193,7 +219,7 @@ class VolumeOpener(
} }
fingerprintProtector.savePasswordHash(volume, returnedHash.value!!) fingerprintProtector.savePasswordHash(volume, returnedHash.value!!)
} else { } else {
callbacks.onVolumeOpened(encryptedVolume, volume.shortName) callbacks.onVolumeOpened(volumeManager.insert(encryptedVolume, volume))
} }
} }
} }

View File

@ -0,0 +1,17 @@
package sushi.hardcore.droidfs
import android.app.Service
import android.content.Intent
import android.os.IBinder
class WiperService : Service() {
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onTaskRemoved(rootIntent: Intent) {
super.onTaskRemoved(rootIntent)
(application as VolumeManagerApp).volumeManager.closeAll()
stopSelf()
}
}

View File

@ -2,7 +2,6 @@ package sushi.hardcore.droidfs.adapters
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.util.LruCache import android.util.LruCache
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -11,13 +10,12 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.DrawableImageViewTarget
import com.bumptech.glide.request.transition.Transition
import kotlinx.coroutines.* import kotlinx.coroutines.*
import sushi.hardcore.droidfs.ConstValues 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.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat import sushi.hardcore.droidfs.filesystems.Stat
@ -29,7 +27,7 @@ class ExplorerElementAdapter(
val activity: AppCompatActivity, val activity: AppCompatActivity,
val encryptedVolume: EncryptedVolume?, val encryptedVolume: EncryptedVolume?,
private val listener: Listener, private val listener: Listener,
val thumbnailMaxSize: Long, thumbnailMaxSize: Long,
) : SelectableAdapter<ExplorerElement>(listener::onSelectionChanged) { ) : SelectableAdapter<ExplorerElement>(listener::onSelectionChanged) {
val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.getDefault()) val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.getDefault())
var explorerElements = listOf<ExplorerElement>() var explorerElements = listOf<ExplorerElement>()
@ -40,12 +38,18 @@ class ExplorerElementAdapter(
notifyDataSetChanged() notifyDataSetChanged()
} }
var isUsingListLayout = true var isUsingListLayout = true
private var thumbnailsLoader: ThumbnailsLoader? = null
private var thumbnailsCache: LruCache<String, Bitmap>? = null private var thumbnailsCache: LruCache<String, Bitmap>? = null
var loadThumbnails = true var loadThumbnails = true
init { init {
if (encryptedVolume != null) { if (encryptedVolume != null) {
thumbnailsCache = LruCache((Runtime.getRuntime().maxMemory() / 1024 / 8).toInt()) 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
}
} }
} }
@ -115,40 +119,11 @@ class ExplorerElementAdapter(
} }
class FileViewHolder(itemView: View) : RegularElementViewHolder(itemView) { class FileViewHolder(itemView: View) : RegularElementViewHolder(itemView) {
private var target: DrawableImageViewTarget? = null private var task = -1
private var job: Job? = null
private val scope = CoroutineScope(Dispatchers.IO)
private fun loadThumbnail(fullPath: String, adapter: ExplorerElementAdapter) {
adapter.encryptedVolume?.let { volume ->
job = scope.launch {
volume.loadWholeFile(fullPath, maxSize = adapter.thumbnailMaxSize).first?.let {
if (isActive) {
withContext(Dispatchers.Main) {
if (isActive && !adapter.activity.isFinishing) {
target = Glide.with(adapter.activity).load(it).skipMemoryCache(true).into(object : DrawableImageViewTarget(icon) {
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
target = null
val bitmap = resource.toBitmap()
adapter.thumbnailsCache!!.put(fullPath, bitmap.copy(bitmap.config, true))
super.onResourceReady(resource, transition)
}
})
}
}
}
}
}
}
}
fun cancelThumbnailLoading(adapter: ExplorerElementAdapter) { fun cancelThumbnailLoading(adapter: ExplorerElementAdapter) {
job?.cancel() if (task != -1) {
target?.let { adapter.thumbnailsLoader?.cancel(task)
Glide.with(adapter.activity).clear(it)
} }
} }
@ -161,7 +136,10 @@ class ExplorerElementAdapter(
icon.setImageBitmap(thumbnail) icon.setImageBitmap(thumbnail)
setDefaultIcon = false setDefaultIcon = false
} else if (adapter.loadThumbnails) { } else if (adapter.loadThumbnails) {
loadThumbnail(fullPath, adapter) task = adapter.thumbnailsLoader!!.loadAsync(fullPath, icon) { resource ->
val bitmap = resource.toBitmap()
adapter.thumbnailsCache!!.put(fullPath, bitmap.copy(bitmap.config, true))
}
} }
} }
} }
@ -173,17 +151,17 @@ class ExplorerElementAdapter(
override fun bind(explorerElement: ExplorerElement, position: Int, isSelected: Boolean) { override fun bind(explorerElement: ExplorerElement, position: Int, isSelected: Boolean) {
super.bind(explorerElement, position, isSelected) super.bind(explorerElement, position, isSelected)
when { when {
ConstValues.isImage(explorerElement.name) -> { FileTypes.isImage(explorerElement.name) -> {
setThumbnailOrDefaultIcon(explorerElement.fullPath, R.drawable.icon_file_image) setThumbnailOrDefaultIcon(explorerElement.fullPath, R.drawable.icon_file_image)
} }
ConstValues.isVideo(explorerElement.name) -> { FileTypes.isVideo(explorerElement.name) -> {
setThumbnailOrDefaultIcon(explorerElement.fullPath, R.drawable.icon_file_video) setThumbnailOrDefaultIcon(explorerElement.fullPath, R.drawable.icon_file_video)
} }
else -> icon.setImageResource( else -> icon.setImageResource(
when { when {
ConstValues.isText(explorerElement.name) -> R.drawable.icon_file_text FileTypes.isText(explorerElement.name) -> R.drawable.icon_file_text
ConstValues.isPDF(explorerElement.name) -> R.drawable.icon_file_pdf FileTypes.isPDF(explorerElement.name) -> R.drawable.icon_file_pdf
ConstValues.isAudio(explorerElement.name) -> R.drawable.icon_file_audio FileTypes.isAudio(explorerElement.name) -> R.drawable.icon_file_audio
else -> R.drawable.icon_file_unknown else -> R.drawable.icon_file_unknown
} }
) )

View File

@ -8,15 +8,18 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeData import sushi.hardcore.droidfs.VolumeData
import sushi.hardcore.droidfs.VolumeDatabase import sushi.hardcore.droidfs.VolumeDatabase
import sushi.hardcore.droidfs.VolumeManager
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
class VolumeAdapter( class VolumeAdapter(
private val context: Context, private val context: Context,
private val volumeDatabase: VolumeDatabase, private val volumeDatabase: VolumeDatabase,
private val volumeManager: VolumeManager,
private val allowSelection: Boolean, private val allowSelection: Boolean,
private val showReadOnly: Boolean, private val showReadOnly: Boolean,
private val listener: Listener, private val listener: Listener,
@ -80,21 +83,19 @@ class VolumeAdapter(
fun bind(position: Int) { fun bind(position: Int) {
val volume = volumes[position] val volume = volumes[position]
itemView.findViewById<TextView>(R.id.text_volume_name).text = volume.shortName itemView.findViewById<TextView>(R.id.text_volume_name).text = volume.shortName
itemView.findViewById<ImageView>(R.id.image_icon).setImageResource(R.drawable.icon_volume)
itemView.findViewById<TextView>(R.id.text_path).text = if (volume.isHidden) itemView.findViewById<TextView>(R.id.text_path).text = if (volume.isHidden)
context.getString(R.string.hidden_volume) context.getString(R.string.hidden_volume)
else else
volume.name volume.name
itemView.findViewById<ImageView>(R.id.icon_fingerprint).visibility = if (volume.encryptedHash == null) { itemView.findViewById<ImageView>(R.id.icon_unlocked).isVisible = volumeManager.isOpen(volume)
View.GONE itemView.findViewById<ImageView>(R.id.icon_fingerprint).isVisible = volume.encryptedHash != null
} else {
View.VISIBLE
}
itemView.findViewById<TextView>(R.id.text_info).text = context.getString( itemView.findViewById<TextView>(R.id.text_info).text = context.getString(
if (volume.canWrite(context.filesDir.path)) { if (volume.canWrite(context.filesDir.path)) {
R.string.volume_type R.string.volume_type
} else { } else if (volume.canRead(context.filesDir.path)) {
R.string.volume_type_read_only R.string.volume_type_read_only
} else {
R.string.volume_type_inaccessible
}, },
context.getString(if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) { context.getString(if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) {
R.string.gocryptfs R.string.gocryptfs

View File

@ -4,11 +4,8 @@ import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.addCallback import androidx.activity.addCallback
import sushi.hardcore.droidfs.* import sushi.hardcore.droidfs.*
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
import sushi.hardcore.droidfs.databinding.ActivityAddVolumeBinding import sushi.hardcore.droidfs.databinding.ActivityAddVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerRouter import sushi.hardcore.droidfs.explorers.ExplorerRouter
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.IntentUtils
class AddVolumeActivity: BaseActivity() { class AddVolumeActivity: BaseActivity() {
@ -19,15 +16,12 @@ class AddVolumeActivity: BaseActivity() {
private lateinit var binding: ActivityAddVolumeBinding private lateinit var binding: ActivityAddVolumeBinding
private lateinit var explorerRouter: ExplorerRouter private lateinit var explorerRouter: ExplorerRouter
private lateinit var volumeOpener: VolumeOpener private lateinit var volumeOpener: VolumeOpener
private var usfKeepOpen = false
var shouldCloseVolume = true // used when launched to pick file from another volume
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
binding = ActivityAddVolumeBinding.inflate(layoutInflater) binding = ActivityAddVolumeBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
usfKeepOpen = sharedPrefs.getBoolean("usf_keep_open", false)
explorerRouter = ExplorerRouter(this, intent) explorerRouter = ExplorerRouter(this, intent)
volumeOpener = VolumeOpener(this) volumeOpener = VolumeOpener(this)
if (savedInstanceState == null) { if (savedInstanceState == null) {
@ -35,13 +29,12 @@ class AddVolumeActivity: BaseActivity() {
.beginTransaction() .beginTransaction()
.add( .add(
R.id.fragment_container, R.id.fragment_container,
SelectPathFragment.newInstance(themeValue, explorerRouter.pickMode), SelectPathFragment.newInstance(theme, explorerRouter.pickMode),
) )
.commit() .commit()
} }
onBackPressedDispatcher.addCallback(this) { onBackPressedDispatcher.addCallback(this) {
setResult(RESULT_USER_BACK) setResult(RESULT_USER_BACK)
shouldCloseVolume = false
isEnabled = false isEnabled = false
onBackPressedDispatcher.onBackPressed() onBackPressedDispatcher.onBackPressed()
} }
@ -53,7 +46,6 @@ class AddVolumeActivity: BaseActivity() {
supportFragmentManager.popBackStack() supportFragmentManager.popBackStack()
else { else {
setResult(RESULT_USER_BACK) setResult(RESULT_USER_BACK)
shouldCloseVolume = false
finish() finish()
} }
} }
@ -70,21 +62,19 @@ class AddVolumeActivity: BaseActivity() {
) )
} }
fun startExplorer(encryptedVolume: EncryptedVolume, volumeShortName: String) { fun startExplorer(volumeId: Int, volumeShortName: String) {
startActivity(explorerRouter.getExplorerIntent(encryptedVolume, volumeShortName)) startActivity(explorerRouter.getExplorerIntent(volumeId, volumeShortName))
shouldCloseVolume = false
finish() finish()
} }
fun onVolumeSelected(volume: VolumeData, rememberVolume: Boolean) { fun onVolumeSelected(volume: VolumeData, rememberVolume: Boolean) {
if (rememberVolume) { if (rememberVolume) {
setResult(RESULT_USER_BACK) setResult(RESULT_USER_BACK)
shouldCloseVolume = false
finish() finish()
} else { } else {
volumeOpener.openVolume(volume, false, object : VolumeOpener.VolumeOpenerCallbacks { volumeOpener.openVolume(volume, false, object : VolumeOpener.VolumeOpenerCallbacks {
override fun onVolumeOpened(encryptedVolume: EncryptedVolume, volumeShortName: String) { override fun onVolumeOpened(id: Int) {
startExplorer(encryptedVolume, volumeShortName) startExplorer(id, volume.shortName)
} }
}) })
} }
@ -95,29 +85,15 @@ class AddVolumeActivity: BaseActivity() {
.beginTransaction() .beginTransaction()
.replace( .replace(
R.id.fragment_container, CreateVolumeFragment.newInstance( R.id.fragment_container, CreateVolumeFragment.newInstance(
themeValue, theme,
volumePath, volumePath,
isHidden, isHidden,
rememberVolume, rememberVolume,
sharedPrefs.getBoolean(ConstValues.PIN_PASSWORDS_KEY, false), sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false),
sharedPrefs.getBoolean("usf_fingerprint", false), sharedPrefs.getBoolean("usf_fingerprint", false),
) )
) )
.addToBackStack(null) .addToBackStack(null)
.commit() .commit()
} }
override fun onStart() {
super.onStart()
shouldCloseVolume = true
}
override fun onStop() {
super.onStop()
if (explorerRouter.pickMode && !usfKeepOpen && shouldCloseVolume) {
IntentUtils.getParcelableExtra<EncryptedVolume>(intent, "volume")?.close()
RestrictedFileProvider.wipeAll(this)
finish()
}
}
} }

View File

@ -18,8 +18,9 @@ import sushi.hardcore.droidfs.databinding.FragmentCreateVolumeBinding
import sushi.hardcore.droidfs.filesystems.CryfsVolume import sushi.hardcore.droidfs.filesystems.CryfsVolume
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.ObjRef import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.WidgetUtil import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import java.io.File
import java.util.* import java.util.*
@ -30,11 +31,11 @@ class CreateVolumeFragment: Fragment() {
private const val KEY_VOLUME_PATH = "path" private const val KEY_VOLUME_PATH = "path"
private const val KEY_IS_HIDDEN = "hidden" private const val KEY_IS_HIDDEN = "hidden"
private const val KEY_REMEMBER_VOLUME = "remember" private const val KEY_REMEMBER_VOLUME = "remember"
private const val KEY_PIN_PASSWORDS = ConstValues.PIN_PASSWORDS_KEY private const val KEY_PIN_PASSWORDS = Constants.PIN_PASSWORDS_KEY
private const val KEY_USF_FINGERPRINT = "fingerprint" private const val KEY_USF_FINGERPRINT = "fingerprint"
fun newInstance( fun newInstance(
themeValue: String, theme: Theme,
volumePath: String, volumePath: String,
isHidden: Boolean, isHidden: Boolean,
rememberVolume: Boolean, rememberVolume: Boolean,
@ -43,7 +44,7 @@ class CreateVolumeFragment: Fragment() {
): CreateVolumeFragment { ): CreateVolumeFragment {
return CreateVolumeFragment().apply { return CreateVolumeFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(KEY_THEME_VALUE, themeValue) putParcelable(KEY_THEME_VALUE, theme)
putString(KEY_VOLUME_PATH, volumePath) putString(KEY_VOLUME_PATH, volumePath)
putBoolean(KEY_IS_HIDDEN, isHidden) putBoolean(KEY_IS_HIDDEN, isHidden)
putBoolean(KEY_REMEMBER_VOLUME, rememberVolume) putBoolean(KEY_REMEMBER_VOLUME, rememberVolume)
@ -55,7 +56,7 @@ class CreateVolumeFragment: Fragment() {
} }
private lateinit var binding: FragmentCreateVolumeBinding private lateinit var binding: FragmentCreateVolumeBinding
private var themeValue = ConstValues.DEFAULT_THEME_VALUE private lateinit var theme: Theme
private val volumeTypes = ArrayList<String>(2) private val volumeTypes = ArrayList<String>(2)
private lateinit var volumePath: String private lateinit var volumePath: String
private var isHiddenVolume: Boolean = false private var isHiddenVolume: Boolean = false
@ -76,7 +77,7 @@ class CreateVolumeFragment: Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val pinPasswords = requireArguments().let { arguments -> val pinPasswords = requireArguments().let { arguments ->
arguments.getString(KEY_THEME_VALUE)?.let { themeValue = it } theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
volumePath = arguments.getString(KEY_VOLUME_PATH)!! volumePath = arguments.getString(KEY_VOLUME_PATH)!!
isHiddenVolume = arguments.getBoolean(KEY_IS_HIDDEN) isHiddenVolume = arguments.getBoolean(KEY_IS_HIDDEN)
rememberVolume = arguments.getBoolean(KEY_REMEMBER_VOLUME) rememberVolume = arguments.getBoolean(KEY_REMEMBER_VOLUME)
@ -85,7 +86,7 @@ class CreateVolumeFragment: Fragment() {
} }
volumeDatabase = VolumeDatabase(requireContext()) volumeDatabase = VolumeDatabase(requireContext())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
fingerprintProtector = FingerprintProtector.new(requireActivity(), themeValue, volumeDatabase) fingerprintProtector = FingerprintProtector.new(requireActivity(), theme, volumeDatabase)
} }
if (!rememberVolume || !usfFingerprint || fingerprintProtector == null) { if (!rememberVolume || !usfFingerprint || fingerprintProtector == null) {
binding.checkboxSavePassword.visibility = View.GONE binding.checkboxSavePassword.visibility = View.GONE
@ -145,8 +146,8 @@ class CreateVolumeFragment: Fragment() {
} }
private fun createVolume() { private fun createVolume() {
val password = WidgetUtil.encodeEditTextContent(binding.editPassword) val password = UIUtils.encodeEditTextContent(binding.editPassword)
val passwordConfirm = WidgetUtil.encodeEditTextContent(binding.editPasswordConfirm) val passwordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
if (!password.contentEquals(passwordConfirm)) { if (!password.contentEquals(passwordConfirm)) {
Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
Arrays.fill(password, 0) Arrays.fill(password, 0)
@ -158,12 +159,8 @@ class CreateVolumeFragment: Fragment() {
} else { } else {
null null
} }
val encryptedVolume = if (rememberVolume) { val encryptedVolume = ObjRef<EncryptedVolume?>(null)
null object: LoadingTask<Byte>(requireActivity() as AppCompatActivity, theme, R.string.loading_msg_create) {
} else {
ObjRef<EncryptedVolume?>(null)
}
object: LoadingTask<Byte>(requireActivity() as AppCompatActivity, themeValue, R.string.loading_msg_create) {
private fun generateResult(success: Boolean, volumeType: Byte): Byte { private fun generateResult(success: Boolean, volumeType: Byte): Byte {
return if (success) { return if (success) {
volumeType volumeType
@ -193,36 +190,39 @@ class CreateVolumeFragment: Fragment() {
encryptedVolume, encryptedVolume,
), EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) ), EncryptedVolume.GOCRYPTFS_VOLUME_TYPE)
} else { } else {
generateResult(CryfsVolume.create( encryptedVolume.value = CryfsVolume.create(
volumePath, volumePath,
CryfsVolume.getLocalStateDir(activity.filesDir.path), CryfsVolume.getLocalStateDir(activity.filesDir.path),
password, password,
returnedHash, returnedHash,
resources.getStringArray(R.array.cryfs_encryption_ciphers)[binding.spinnerCipher.selectedItemPosition], resources.getStringArray(R.array.cryfs_encryption_ciphers)[binding.spinnerCipher.selectedItemPosition],
encryptedVolume, )
), EncryptedVolume.CRYFS_VOLUME_TYPE) generateResult(encryptedVolume.value != null, EncryptedVolume.CRYFS_VOLUME_TYPE)
} }
Arrays.fill(password, 0) Arrays.fill(password, 0)
return result return result
} }
}.startTask(lifecycleScope) { result -> }.startTask(lifecycleScope) { result ->
if (result.compareTo(-1) == 0) { if (result.compareTo(-1) == 0) {
CustomAlertDialogBuilder(requireContext(), themeValue) CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.create_volume_failed) .setMessage(R.string.create_volume_failed)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} else { } else {
val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath
val volume = VolumeData(volumeName, isHiddenVolume, result) val volume = VolumeData(VolumeData.newUuid(), volumeName, isHiddenVolume, result)
var isVolumeSaved = false var isVolumeSaved = false
volumeDatabase.apply { volumeDatabase.apply {
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
removeVolume(volumeName) removeVolume(volume)
if (rememberVolume) { if (rememberVolume) {
isVolumeSaved = saveVolume(volume) 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 @SuppressLint("NewApi") // if fingerprintProtector is null checkboxSavePassword is hidden
if (isVolumeSaved && binding.checkboxSavePassword.isChecked && returnedHash != null) { if (isVolumeSaved && binding.checkboxSavePassword.isChecked && returnedHash != null) {
fingerprintProtector!!.let { fingerprintProtector!!.let {
@ -235,32 +235,38 @@ class CreateVolumeFragment: Fragment() {
override fun onPasswordHashDecrypted(hash: ByteArray) {} // shouldn't happen here override fun onPasswordHashDecrypted(hash: ByteArray) {} // shouldn't happen here
override fun onPasswordHashSaved() { override fun onPasswordHashSaved() {
Arrays.fill(returnedHash.value!!, 0) Arrays.fill(returnedHash.value!!, 0)
onVolumeCreated(encryptedVolume?.value, volume.shortName) onVolumeCreated(volumeId, volume.shortName)
} }
override fun onFailed(pending: Boolean) { override fun onFailed(pending: Boolean) {
if (!pending) { if (!pending) {
Arrays.fill(returnedHash.value!!, 0) Arrays.fill(returnedHash.value!!, 0)
onVolumeCreated(encryptedVolume?.value, volume.shortName) onVolumeCreated(volumeId, volume.shortName)
} }
} }
} }
it.savePasswordHash(volume, returnedHash.value!!) it.savePasswordHash(volume, returnedHash.value!!)
} }
} else { } else {
onVolumeCreated(encryptedVolume?.value, volume.shortName) onVolumeCreated(volumeId, volume.shortName)
} }
} }
} }
} }
} }
private fun onVolumeCreated(encryptedVolume: EncryptedVolume?, volumeShortName: String) { private fun onVolumeCreated(id: Int?, volumeShortName: String) {
(activity as AddVolumeActivity).apply { (activity as AddVolumeActivity).apply {
if (rememberVolume || encryptedVolume == null) { if (id == null) {
finish() finish()
} else { } else {
startExplorer(encryptedVolume, volumeShortName) startExplorer(id, volumeShortName)
} }
} }
} }
override fun onStop() {
super.onStop()
binding.editPassword.text.clear()
binding.editPasswordConfirm.text.clear()
}
} }

View File

@ -2,11 +2,14 @@ package sushi.hardcore.droidfs.add_volume
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.view.LayoutInflater import android.view.LayoutInflater
@ -17,13 +20,16 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.Theme
import sushi.hardcore.droidfs.VolumeData import sushi.hardcore.droidfs.VolumeData
import sushi.hardcore.droidfs.VolumeDatabase import sushi.hardcore.droidfs.VolumeDatabase
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.databinding.DialogSdcardErrorBinding import sushi.hardcore.droidfs.databinding.DialogSdcardErrorBinding
import sushi.hardcore.droidfs.databinding.FragmentSelectPathBinding import sushi.hardcore.droidfs.databinding.FragmentSelectPathBinding
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import java.io.File
@ -33,10 +39,10 @@ class SelectPathFragment: Fragment() {
private const val KEY_THEME_VALUE = "theme" private const val KEY_THEME_VALUE = "theme"
private const val KEY_PICK_MODE = "pick" private const val KEY_PICK_MODE = "pick"
fun newInstance(themeValue: String, pickMode: Boolean): SelectPathFragment { fun newInstance(theme: Theme, pickMode: Boolean): SelectPathFragment {
return SelectPathFragment().apply { return SelectPathFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(KEY_THEME_VALUE, themeValue) putParcelable(KEY_THEME_VALUE, theme)
putBoolean(KEY_PICK_MODE, pickMode) putBoolean(KEY_PICK_MODE, pickMode)
} }
} }
@ -48,7 +54,7 @@ class SelectPathFragment: Fragment() {
if (result[Manifest.permission.READ_EXTERNAL_STORAGE] == true && result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true) if (result[Manifest.permission.READ_EXTERNAL_STORAGE] == true && result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true)
launchPickDirectory() launchPickDirectory()
else else
CustomAlertDialogBuilder(requireContext(), themeValue) CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.storage_perm_denied) .setTitle(R.string.storage_perm_denied)
.setMessage(R.string.storage_perm_denied_msg) .setMessage(R.string.storage_perm_denied_msg)
.setCancelable(false) .setCancelable(false)
@ -59,7 +65,8 @@ class SelectPathFragment: Fragment() {
if (uri != null) if (uri != null)
onDirectoryPicked(uri) onDirectoryPicked(uri)
} }
private var themeValue = ConstValues.DEFAULT_THEME_VALUE private lateinit var app: VolumeManagerApp
private lateinit var theme: Theme
private lateinit var volumeDatabase: VolumeDatabase private lateinit var volumeDatabase: VolumeDatabase
private lateinit var filesDir: String private lateinit var filesDir: String
private lateinit var sharedPrefs: SharedPreferences private lateinit var sharedPrefs: SharedPreferences
@ -78,11 +85,12 @@ class SelectPathFragment: Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
app = requireActivity().application as VolumeManagerApp
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
originalRememberVolume = sharedPrefs.getBoolean(ConstValues.REMEMBER_VOLUME_KEY, true) originalRememberVolume = sharedPrefs.getBoolean(Constants.REMEMBER_VOLUME_KEY, true)
binding.switchRemember.isChecked = originalRememberVolume binding.switchRemember.isChecked = originalRememberVolume
arguments?.let { arguments -> arguments?.let { arguments ->
arguments.getString(KEY_THEME_VALUE)?.let { themeValue = it } theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
pickMode = arguments.getBoolean(KEY_PICK_MODE) pickMode = arguments.getBoolean(KEY_PICK_MODE)
} }
if (pickMode) { if (pickMode) {
@ -98,27 +106,37 @@ class SelectPathFragment: Fragment() {
refreshStatus(binding.editVolumeName.text) refreshStatus(binding.editVolumeName.text)
} }
binding.buttonPickDirectory.setOnClickListener { binding.buttonPickDirectory.setOnClickListener {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (ContextCompat.checkSelfPermission( 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(), requireContext(),
Manifest.permission.READ_EXTERNAL_STORAGE Manifest.permission.READ_EXTERNAL_STORAGE
) + ) + ContextCompat.checkSelfPermission(
ContextCompat.checkSelfPermission(
requireContext(), requireContext(),
Manifest.permission.WRITE_EXTERNAL_STORAGE Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
) ) {
launchPickDirectory() launchPickDirectory()
else } else {
app.isStartingExternalApp = true
askStoragePermissions.launch( askStoragePermissions.launch(
arrayOf( arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE Manifest.permission.WRITE_EXTERNAL_STORAGE
) )
) )
} else }
} else {
launchPickDirectory() launchPickDirectory()
} }
}
binding.editVolumeName.addTextChangedListener(object: TextWatcher { binding.editVolumeName.addTextChangedListener(object: TextWatcher {
override fun afterTextChanged(s: Editable?) {} override fun afterTextChanged(s: Editable?) {}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
@ -138,8 +156,8 @@ class SelectPathFragment: Fragment() {
} }
private fun launchPickDirectory() { private fun launchPickDirectory() {
(activity as AddVolumeActivity).shouldCloseVolume = false app.isStartingExternalApp = true
PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue) PathUtils.safePickDirectory(pickDirectory, requireContext(), theme)
} }
private fun showRightSection() { private fun showRightSection() {
@ -201,7 +219,7 @@ class SelectPathFragment: Fragment() {
if (path != null) if (path != null)
binding.editVolumeName.setText(path) binding.editVolumeName.setText(path)
else else
CustomAlertDialogBuilder(requireContext(), themeValue) CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.path_error) .setMessage(R.string.path_error)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@ -218,7 +236,7 @@ class SelectPathFragment: Fragment() {
private fun onPathSelected() { private fun onPathSelected() {
if (binding.switchRemember.isChecked != originalRememberVolume) { if (binding.switchRemember.isChecked != originalRememberVolume) {
with(sharedPrefs.edit()) { with(sharedPrefs.edit()) {
putBoolean(ConstValues.REMEMBER_VOLUME_KEY, binding.switchRemember.isChecked) putBoolean(Constants.REMEMBER_VOLUME_KEY, binding.switchRemember.isChecked)
apply() apply()
} }
} }
@ -234,7 +252,7 @@ class SelectPathFragment: Fragment() {
} else if (isHidden && currentVolumeValue.contains(PathUtils.SEPARATOR)) { } else if (isHidden && currentVolumeValue.contains(PathUtils.SEPARATOR)) {
Toast.makeText(requireContext(), R.string.error_slash_in_name, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
} else if (isHidden && volumeAction == Action.CREATE) { } else if (isHidden && volumeAction == Action.CREATE) {
CustomAlertDialogBuilder(requireContext(), themeValue) CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage(R.string.hidden_volume_warning) .setMessage(R.string.hidden_volume_warning)
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ ->
@ -286,13 +304,13 @@ class SelectPathFragment: Fragment() {
Action.ADD -> { Action.ADD -> {
val volumeType = EncryptedVolume.getVolumeType(volumePath) val volumeType = EncryptedVolume.getVolumeType(volumePath)
if (volumeType < 0) { if (volumeType < 0) {
CustomAlertDialogBuilder(requireContext(), themeValue) CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.error_not_a_volume) .setMessage(R.string.error_not_a_volume)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} else if (!File(volumePath).canWrite()) { } else if (!File(volumePath).canWrite()) {
val dialog = CustomAlertDialogBuilder(requireContext(), themeValue) val dialog = CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setCancelable(false) .setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) } .setPositiveButton(R.string.ok) { _, _ -> addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
@ -316,7 +334,7 @@ class SelectPathFragment: Fragment() {
// called when the user tries to create a volume in a non-writable directory // called when the user tries to create a volume in a non-writable directory
private fun errorDirectoryNotWritable(volumePath: String) { private fun errorDirectoryNotWritable(volumePath: String) {
val dialog = CustomAlertDialogBuilder(requireContext(), themeValue) val dialog = CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@SuppressLint("InflateParams") @SuppressLint("InflateParams")
@ -332,7 +350,7 @@ class SelectPathFragment: Fragment() {
} }
private fun addVolume(volumeName: String, isHidden: Boolean, volumeType: Byte) { private fun addVolume(volumeName: String, isHidden: Boolean, volumeType: Byte) {
val volumeData = VolumeData(volumeName, isHidden, volumeType) val volumeData = VolumeData(VolumeData.newUuid(), volumeName, isHidden, volumeType)
if (binding.switchRemember.isChecked) { if (binding.switchRemember.isChecked) {
volumeDatabase.saveVolume(volumeData) volumeDatabase.saveVolume(volumeData)
} }

View File

@ -1,123 +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 androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File
object ExternalProvider {
private const val content_type_all = "*/*"
private var storedFiles = HashSet<Uri>()
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, encryptedVolume: EncryptedVolume, 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 (encryptedVolume.exportFile(context, file_path, tmpFileUri)) {
return Pair(tmpFileUri, getContentType(fileName, previous_content_type))
}
}
return Pair(null, null)
}
fun share(activity: AppCompatActivity, themeValue: String, encryptedVolume: EncryptedVolume, file_paths: List<String>) {
var contentType: String? = null
val uris = ArrayList<Uri>(file_paths.size)
object : LoadingTask<String?>(activity, themeValue, R.string.loading_msg_export) {
override suspend fun doTask(): String? {
for (path in file_paths) {
val result = exportFile(activity, encryptedVolume, path, contentType)
contentType = if (result.first != null) {
uris.add(result.first!!)
result.second
} else {
return path
}
}
return null
}
}.startTask(activity.lifecycleScope) { failedItem ->
if (failedItem == null) {
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)
}
activity.startActivity(Intent.createChooser(shareIntent, activity.getString(R.string.share_chooser)))
} else {
CustomAlertDialogBuilder(activity, themeValue)
.setTitle(R.string.error)
.setMessage(activity.getString(R.string.export_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
}
}
fun open(activity: AppCompatActivity, themeValue: String, encryptedVolume: EncryptedVolume, file_path: String) {
object : LoadingTask<Intent?>(activity, themeValue, R.string.loading_msg_export) {
override suspend fun doTask(): Intent? {
val result = exportFile(activity, encryptedVolume, file_path, null)
return if (result.first != null) {
Intent(Intent.ACTION_VIEW).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
setDataAndType(result.first, result.second)
}
} else {
null
}
}
}.startTask(activity.lifecycleScope) { openIntent ->
if (openIntent == null) {
CustomAlertDialogBuilder(activity, themeValue)
.setTitle(R.string.error)
.setMessage(activity.getString(R.string.export_failed, file_path))
.setPositiveButton(R.string.ok, null)
.show()
} else {
activity.startActivity(openIntent)
}
}
}
fun removeFilesAsync(context: Context) = GlobalScope.launch(Dispatchers.IO) {
val success = HashSet<Uri>(storedFiles.size)
for (uri in storedFiles) {
if (context.contentResolver.delete(uri, null, null) == 1) {
success.add(uri)
}
}
for (uri in success) {
storedFiles.remove(uri)
}
}
}

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 androidx.preference.PreferenceManager
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.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
var usfSafWrite = false
}
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
usfSafWrite =
PreferenceManager.getDefaultSharedPreferences(it).getBoolean("usf_saf_write", false)
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,293 @@
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.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,
)
var usfExpose = false
var usfSafWrite = false
fun notifyRootsChanged(context: Context) {
context.contentResolver.notifyChange(DocumentsContract.buildRootsUri(AUTHORITY), null)
}
}
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)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
usfExpose = sharedPreferences.getBoolean("usf_expose", false)
usfSafWrite = sharedPreferences.getBoolean("usf_saf_write", false)
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

@ -11,9 +11,12 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.addCallback
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
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.lifecycle.lifecycleScope
@ -21,26 +24,37 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async
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.isAudio import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.ConstValues.isImage import sushi.hardcore.droidfs.FileShare
import sushi.hardcore.droidfs.ConstValues.isPDF import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.ConstValues.isText import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.ConstValues.isVideo
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
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_viewers.* import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.file_viewers.AudioPlayer
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.VideoPlayer
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -50,6 +64,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
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 var volumeId = -1
protected lateinit var encryptedVolume: EncryptedVolume 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
@ -59,39 +74,42 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
explorerViewModel.currentDirectoryPath = value explorerViewModel.currentDirectoryPath = value
} }
protected lateinit var fileOperationService: FileOperationService protected lateinit var fileOperationService: FileOperationService
protected val taskScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) 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 linearLayoutManager: LinearLayoutManager
private var isUsingListLayout = true private var isUsingListLayout = true
private lateinit var layoutIcon: ImageButton private lateinit var layoutIcon: ImageButton
private lateinit var titleText: TextView private lateinit var titleText: TextView
private lateinit var recycler_view_explorer: RecyclerView 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 numberOfFilesText: TextView
private lateinit var numberOfFoldersText: 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)
encryptedVolume = IntentUtils.getParcelableExtra(intent, "volume")!! encryptedVolume = app.volumeManager.getVolume(volumeId)!!
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()
recycler_view_explorer = findViewById(R.id.recycler_view_explorer) recycler_view_explorer = findViewById(R.id.recycler_view_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) numberOfFilesText = findViewById(R.id.number_of_files_text)
@ -112,16 +130,28 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
null null
}, },
this, this,
sharedPrefs.getLong(ConstValues.THUMBNAIL_MAX_SIZE_KEY, ConstValues.DEFAULT_THUMBNAIL_MAX_SIZE)*1000, sharedPrefs.getLong(Constants.THUMBNAIL_MAX_SIZE_KEY, Constants.DEFAULT_THUMBNAIL_MAX_SIZE)*1000,
) )
explorerViewModel = ViewModelProvider(this).get(ExplorerViewModel::class.java) explorerViewModel = ViewModelProvider(this).get(ExplorerViewModel::class.java)
currentDirectoryPath = explorerViewModel.currentDirectoryPath currentDirectoryPath = explorerViewModel.currentDirectoryPath
setCurrentPath(currentDirectoryPath)
linearLayoutManager = LinearLayoutManager(this@BaseExplorerActivity) linearLayoutManager = LinearLayoutManager(this@BaseExplorerActivity)
recycler_view_explorer.adapter = explorerAdapter recycler_view_explorer.adapter = explorerAdapter
isUsingListLayout = sharedPrefs.getBoolean("useListLayout", true) isUsingListLayout = sharedPrefs.getBoolean("useListLayout", true)
layoutIcon = findViewById(R.id.layout_icon) layoutIcon = findViewById(R.id.layout_icon)
setRecyclerViewLayout() 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 { layoutIcon.setOnClickListener {
isUsingListLayout = !isUsingListLayout isUsingListLayout = !isUsingListLayout
setRecyclerViewLayout() setRecyclerViewLayout()
@ -166,33 +196,60 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
override fun onServiceConnected(className: ComponentName, service: IBinder) { override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as FileOperationService.LocalBinder val binder = service as FileOperationService.LocalBinder
fileOperationService = binder.getService() fileOperationService = binder.getService()
binder.setEncryptedVolume(encryptedVolume)
}
override fun onServiceDisconnected(arg0: ComponentName) {
} }
override fun onServiceDisconnected(arg0: ComponentName) {}
}, Context.BIND_AUTO_CREATE) }, 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("volume", encryptedVolume) putExtra("volume", encryptedVolume)
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, themeValue, encryptedVolume, fullPath) .setTitle(R.string.error)
.setMessage(getString(R.string.tmp_export_failed, getString(errorResId)))
.setPositiveButton(R.string.ok, null)
.show()
} }
private fun showOpenAsDialog(path: String) { private fun openWithExternalApp(path: String, size: Long) {
app.isExporting = true
val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
if (exportedFile == null) {
onExportFailed(R.string.export_failed_create)
return
}
val msg = when (exportedFile) {
is EncryptedFileProvider.ExportedMemFile -> R.string.export_mem
is EncryptedFileProvider.ExportedDiskFile -> R.string.export_disk
else -> R.string.loading_msg_export
}
object : LoadingTask<Pair<Intent?, Int?>>(this, theme, msg) {
override suspend fun doTask(): Pair<Intent?, Int?> {
return fileShare.openWith(exportedFile, size, volumeId)
}
}.startTask(lifecycleScope) { (intent, error) ->
if (intent == null) {
onExportFailed(error!!)
} else {
app.isStartingExternalApp = true
startActivity(intent)
}
app.isExporting = false
}
}
private fun showOpenAsDialog(explorerElement: ExplorerElement) {
val path = explorerElement.fullPath
val adapter = OpenAsDialogAdapter(this, usf_open) val adapter = OpenAsDialogAdapter(this, usf_open)
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setSingleChoiceItems(adapter, -1) { dialog, which -> .setSingleChoiceItems(adapter, -1) { dialog, which ->
when (adapter.getItem(which)) { when (adapter.getItem(which)) {
"image" -> startFileViewer(ImageViewer::class.java, path) "image" -> startFileViewer(ImageViewer::class.java, path)
@ -201,7 +258,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
"pdf" -> startFileViewer(PdfViewer::class.java, path) "pdf" -> startFileViewer(PdfViewer::class.java, path)
"text" -> startFileViewer(TextEditor::class.java, path) "text" -> startFileViewer(TextEditor::class.java, path)
"external" -> if (usf_open) { "external" -> if (usf_open) {
openWithExternalApp(path) openWithExternalApp(path, explorerElement.stat.size)
} }
} }
dialog.dismiss() dialog.dismiss()
@ -233,22 +290,22 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
explorerElements[position].isParentFolder -> { explorerElements[position].isParentFolder -> {
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath)) setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
} }
isImage(fullPath) -> { FileTypes.isImage(fullPath) -> {
startFileViewer(ImageViewer::class.java, fullPath) startFileViewer(ImageViewer::class.java, fullPath)
} }
isVideo(fullPath) -> { FileTypes.isVideo(fullPath) -> {
startFileViewer(VideoPlayer::class.java, fullPath) startFileViewer(VideoPlayer::class.java, fullPath)
} }
isText(fullPath) -> { FileTypes.isText(fullPath) -> {
startFileViewer(TextEditor::class.java, fullPath) startFileViewer(TextEditor::class.java, fullPath)
} }
isPDF(fullPath) -> { FileTypes.isPDF(fullPath) -> {
startFileViewer(PdfViewer::class.java, fullPath) startFileViewer(PdfViewer::class.java, fullPath)
} }
isAudio(fullPath) -> { FileTypes.isAudio(fullPath) -> {
startFileViewer(AudioPlayer::class.java, fullPath) startFileViewer(AudioPlayer::class.java, fullPath)
} }
else -> showOpenAsDialog(fullPath) else -> showOpenAsDialog(explorerElements[position])
} }
} }
invalidateOptionsMenu() invalidateOptionsMenu()
@ -263,19 +320,16 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
invalidateOptionsMenu() invalidateOptionsMenu()
} }
private fun displayExplorerElements(totalSize: Long) { private fun displayExplorerElements() {
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
synchronized(this) {
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements) ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
}
unselectAll(false) unselectAll(false)
loader.isVisible = false
recycler_view_explorer.isVisible = true
explorerAdapter.explorerElements = explorerElements explorerAdapter.explorerElements = explorerElements
val sharedPrefsEditor = sharedPrefs.edit()
sharedPrefsEditor.putString(ConstValues.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
sharedPrefsEditor.apply()
} }
private fun recursiveSetSize(directory: ExplorerElement) { private suspend fun recursiveSetSize(directory: ExplorerElement) {
yield()
for (child in encryptedVolume.readDir(directory.fullPath) ?: return) { for (child in encryptedVolume.readDir(directory.fullPath) ?: return) {
if (child.isDirectory) { if (child.isDirectory) {
recursiveSetSize(child) recursiveSetSize(child)
@ -299,26 +353,25 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
} }
} }
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) { protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) = lifecycleScope.launch {
synchronized(this) { directoryLoadingTask?.cancelAndJoin()
explorerElements = encryptedVolume.readDir(path) ?: return recycler_view_explorer.isVisible = false
loader.isVisible = true
explorerElements = encryptedVolume.readDir(path) ?: return@launch
if (path != "/") { if (path != "/") {
explorerElements.add( explorerElements.add(
0, 0,
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath) ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
) )
} }
}
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.GONE 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)
displayNumberOfElements(numberOfFilesText, R.string.one_file, R.string.multiple_files, explorerElements.count { it.isRegularFile }) displayNumberOfElements(numberOfFilesText, R.string.one_file, R.string.multiple_files, explorerElements.count { it.isRegularFile })
displayNumberOfElements(numberOfFoldersText, R.string.one_folder, R.string.multiple_folders, explorerElements.count { it.isDirectory }) displayNumberOfElements(numberOfFoldersText, R.string.one_folder, R.string.multiple_folders, explorerElements.count { it.isDirectory })
if (mapFolders) { if (mapFolders) {
lifecycleScope.launch {
var totalSize: Long = 0 var totalSize: Long = 0
withContext(Dispatchers.IO) { directoryLoadingTask = launch(Dispatchers.IO) {
synchronized(this@BaseExplorerActivity) {
for (element in explorerElements) { for (element in explorerElements) {
if (element.isDirectory) { if (element.isDirectory) {
recursiveSetSize(element) recursiveSetSize(element)
@ -326,44 +379,38 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
totalSize += element.stat.size totalSize += element.stat.size
} }
} }
} directoryLoadingTask!!.join()
displayExplorerElements(totalSize) displayExplorerElements()
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
onDisplayed?.invoke() onDisplayed?.invoke()
}
} else { } else {
displayExplorerElements(explorerElements.filter { !it.isParentFolder }.sumOf { it.stat.size }) displayExplorerElements()
totalSizeText.text = getString(
R.string.total_size,
PathUtils.formatSize(explorerElements.filter { !it.isParentFolder }.sumOf { it.stat.size })
)
onDisplayed?.invoke() onDisplayed?.invoke()
} }
} }
private fun askCloseVolume() { private fun askLockVolume() {
CustomAlertDialogBuilder(this, themeValue) 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 (!encryptedVolume.mkdir(PathUtils.pathJoin(currentDirectoryPath, folderName))) { if (!encryptedVolume.mkdir(PathUtils.pathJoin(currentDirectoryPath, folderName))) {
CustomAlertDialogBuilder(this, themeValue) 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)
@ -381,10 +428,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}.show() }.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].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].srcPath)) testDstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].srcPath))
@ -400,7 +447,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
} }
} }
if (!ready){ if (!ready){
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage(getString( .setMessage(getString(
if (items[i].isDirectory) { if (items[i].isDirectory) {
@ -418,7 +465,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
with(EditTextDialog(this, R.string.enter_new_name) { with(EditTextDialog(this, R.string.enter_new_name) {
items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].parentPath), it) items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].parentPath), it)
if (items[i].isDirectory) { if (items[i].isDirectory) {
for (j in 0 until items.size){ for (j in items.indices) {
if (PathUtils.isChildOf(items[j].srcPath, items[i].srcPath)) { if (PathUtils.isChildOf(items[j].srcPath, items[i].srcPath)) {
items[j].dstPath = PathUtils.pathJoin(items[i].dstPath!!, PathUtils.getRelativePath(items[i].srcPath, items[j].srcPath)) items[j].dstPath = PathUtils.pathJoin(items[i].dstPath!!, PathUtils.getRelativePath(items[i].srcPath, items[j].srcPath))
} }
@ -445,12 +492,38 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
} }
} }
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) {
CustomAlertDialogBuilder(this, themeValue) 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)
@ -464,13 +537,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
if (items.size > 0) { if (items.size > 0) {
checkPathOverwrite(items, currentDirectoryPath) { checkedItems -> checkPathOverwrite(items, currentDirectoryPath) { checkedItems ->
checkedItems?.let { checkedItems?.let {
taskScope.launch { activityScope.launch {
val taskResult = fileOperationService.importFilesFromUris(checkedItems.map { it.dstPath!! }, uris) val result = fileOperationService.importFilesFromUris(volumeId, checkedItems.map { it.dstPath!! }, uris)
if (taskResult.cancelled) { onTaskResult(result, R.string.import_failed, onSuccess = callback)
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath)
} else {
callback(taskResult.failedItem)
}
} }
} }
} }
@ -482,7 +552,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
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 (!encryptedVolume.rename(PathUtils.pathJoin(currentDirectoryPath, old_name), PathUtils.pathJoin(currentDirectoryPath, new_name))) { if (!encryptedVolume.rename(PathUtils.pathJoin(currentDirectoryPath, old_name), PathUtils.pathJoin(currentDirectoryPath, new_name))) {
CustomAlertDialogBuilder(this, themeValue) 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)
@ -495,14 +565,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
} }
} }
private fun setMenuIconTint(menu: Menu, iconColor: Int, menuItemId: Int, drawableId: Int) {
menu.findItem(menuItemId)?.let {
it.icon = ContextCompat.getDrawable(this, drawableId)?.apply {
setTint(iconColor)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean { 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 menu.findItem(R.id.open_as)?.isVisible = false
@ -510,11 +572,12 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
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()
val iconColor = ContextCompat.getColor(this, R.color.neutralIconTint) with(UIUtils.getMenuIconNeutralTint(this, menu)) {
setMenuIconTint(menu, iconColor, R.id.sort, R.drawable.icon_sort) applyTo(R.id.sort, R.drawable.icon_sort)
setMenuIconTint(menu, iconColor, R.id.decrypt, R.drawable.icon_decrypt) applyTo(R.id.share, R.drawable.icon_share)
setMenuIconTint(menu, iconColor, 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
supportActionBar?.setDisplayHomeAsUpEnabled(!noItemSelected) supportActionBar?.setDisplayHomeAsUpEnabled(!noItemSelected)
if (!noItemSelected) { if (!noItemSelected) {
@ -538,11 +601,17 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
true true
} }
R.id.sort -> { R.id.sort -> {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.sort_order) .setTitle(R.string.sort_order)
.setSingleChoiceItems(sortOrderEntries, 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)
@ -560,76 +629,45 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
true true
} }
R.id.open_as -> { R.id.open_as -> {
showOpenAsDialog( showOpenAsDialog(explorerElements[explorerAdapter.selectedItems.first()])
PathUtils.pathJoin(
currentDirectoryPath,
explorerElements[explorerAdapter.selectedItems.first()].name
)
)
true true
} }
R.id.external_open -> { R.id.external_open -> {
if (usf_open){ if (usf_open){
openWithExternalApp( val explorerElement = explorerElements[explorerAdapter.selectedItems.first()]
PathUtils.pathJoin( openWithExternalApp(explorerElement.fullPath, explorerElement.stat.size)
currentDirectoryPath,
explorerElements[explorerAdapter.selectedItems.first()].name
)
)
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() {
taskScope.cancel()
if (!encryptedVolume.isClosed()) {
encryptedVolume.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 (encryptedVolume.isClosed()) { if (encryptedVolume.isClosed()) {
finish() finish()
} else { } else {
isStartingActivity = false
ExternalProvider.removeFilesAsync(this)
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath)
} }
} }
}
} }

View File

@ -6,19 +6,19 @@ import android.net.Uri
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
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 com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.CameraActivity import sushi.hardcore.droidfs.CameraActivity
import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.MainActivity 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.file_operations.OperationFile import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -35,169 +35,157 @@ class ExplorerActivity : BaseExplorerActivity() {
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 remoteEncryptedVolume = IntentUtils.getParcelableExtra<EncryptedVolume>(resultIntent, "volume")!! val srcVolumeId = resultIntent.getIntExtra("volumeId", -1)
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){
object : LoadingTask<List<OperationFile>>(this, theme, R.string.discovering_files) {
override suspend fun doTask(): List<OperationFile> {
val operationFiles = ArrayList<OperationFile>()
for (i in paths.indices) { for (i in paths.indices) {
operationFiles.add( operationFiles.add(OperationFile(paths[i], types[i]))
OperationFile(paths[i], types[i])
)
if (types[i] == Stat.S_IFDIR) { if (types[i] == Stat.S_IFDIR) {
remoteEncryptedVolume.recursiveMapFiles(paths[i])?.forEach { srcEncryptedVolume.recursiveMapFiles(paths[i])?.forEach {
operationFiles.add(OperationFile.fromExplorerElement(it)) operationFiles.add(OperationFile.fromExplorerElement(it))
} }
} }
} }
return operationFiles
} }
} else { }.startTask(lifecycleScope) { operationFiles ->
operationFiles.add( importFilesFromVolume(srcVolumeId, operationFiles)
OperationFile(path, Stat.S_IFREG)
)
}
if (operationFiles.size > 0){
checkPathOverwrite(operationFiles, currentDirectoryPath) { items ->
if (items == null) {
remoteEncryptedVolume.close()
} else {
// stop loading thumbnails while writing files
explorerAdapter.loadThumbnails = false
taskScope.launch {
val failedItem = fileOperationService.copyElements(items, remoteEncryptedVolume)
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.success_import, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
explorerAdapter.loadThumbnails = true
setCurrentPath(currentDirectoryPath)
remoteEncryptedVolume.close()
}
} }
} }
} else { } else {
remoteEncryptedVolume.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) contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
importFilesFromUris(uris) {
onImportComplete(uris)
} }
} }
} }
private val pickExportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> private val pickExportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) { if (uri != null) {
taskScope.launch { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val result = fileOperationService.exportFiles(uri, explorerAdapter.selectedItems.map { i -> explorerElements[i] }) val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
if (!result.cancelled) { activityScope.launch {
if (result.failedItem == null) { val result = fileOperationService.exportFiles(volumeId, items, uri)
Toast.makeText(this@ExplorerActivity, R.string.success_export, Toast.LENGTH_SHORT).show() onTaskResult(result, R.string.export_failed, R.string.success_export)
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.export_failed, result.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 {
contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
val tree = DocumentFile.fromTreeUri(this, it)!! //non-null after Lollipop val tree = DocumentFile.fromTreeUri(this, it)!! //non-null after Lollipop
val operation = OperationFile(PathUtils.pathJoin(currentDirectoryPath, tree.name!!), Stat.S_IFDIR) val operation = OperationFile(PathUtils.pathJoin(currentDirectoryPath, tree.name!!), Stat.S_IFDIR)
checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation -> checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation ->
checkedOperation?.let { checkedOperation?.let {
taskScope.launch { activityScope.launch {
val result = fileOperationService.importDirectory(checkedOperation[0].dstPath!!, tree) val result = fileOperationService.importDirectory(volumeId, checkedOperation[0].dstPath!!, tree)
if (result.taskResult.cancelled) { onTaskResult(result.taskResult, R.string.import_failed) {
setCurrentPath(currentDirectoryPath) onImportComplete(result.uris, tree)
} else {
onImportComplete(result.taskResult.failedItem, 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 ->
CustomAlertDialogBuilder(this, themeValue) if (items != null) {
// stop loading thumbnails while writing files
explorerAdapter.loadThumbnails = false
activityScope.launch {
onTaskResult(
fileOperationService.copyElements(
volumeId,
items,
srcVolumeId
), R.string.import_failed, R.string.success_import
)
explorerAdapter.loadThumbnails = true
setCurrentPath(currentDirectoryPath)
}
}
}
}
private fun onImportComplete(urisToWipe: List<Uri>, rootFile: DocumentFile? = null) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import) .setTitle(R.string.success_import)
.setMessage(""" .setMessage("""
${getString(R.string.success_import_msg)} ${getString(R.string.success_import_msg)}
${getString(R.string.ask_for_wipe)} ${getString(R.string.ask_for_wipe)}
""".trimIndent()) """.trimIndent())
.setPositiveButton(R.string.yes) { _, _ -> .setPositiveButton(R.string.yes) { _, _ ->
taskScope.launch { activityScope.launch {
val errorMsg = fileOperationService.wipeUris(urisToWipe, rootFile) onTaskResult(
if (errorMsg == null) { fileOperationService.wipeUris(urisToWipe, rootFile),
Toast.makeText(this@ExplorerActivity, R.string.wipe_successful, Toast.LENGTH_SHORT).show() R.string.wipe_failed,
} else { R.string.wipe_successful,
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue) )
.setTitle(R.string.error)
.setMessage(getString(R.string.wipe_failed, errorMsg))
.setPositiveButton(R.string.ok, null)
.show()
}
} }
} }
.setNegativeButton(R.string.no, null) .setNegativeButton(R.string.no, null)
.show() .show()
} else {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
setCurrentPath(currentDirectoryPath)
} }
override fun init() { override fun init() {
super.init() super.init()
onBackPressedDispatcher.addCallback(this) {
if (currentItemAction != ItemsActions.NONE) {
cancelItemAction()
invalidateOptionsMenu()
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
findViewById<FloatingActionButton>(R.id.fab).setOnClickListener { 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("camera", R.string.camera, R.drawable.icon_photo) listOf("camera", R.string.camera, R.drawable.icon_photo)
) )
CustomAlertDialogBuilder(this, themeValue) 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, MainActivity::class.java) val intent = Intent(this, MainActivity::class.java)
intent.action = "pick" intent.action = "pick"
intent.putExtra("volume", encryptedVolume) intent.putExtra("volume", encryptedVolume)
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" -> {
@ -212,7 +200,6 @@ class ExplorerActivity : BaseExplorerActivity() {
val intent = Intent(this, CameraActivity::class.java) val intent = Intent(this, CameraActivity::class.java)
intent.putExtra("path", currentDirectoryPath) intent.putExtra("path", currentDirectoryPath)
intent.putExtra("volume", encryptedVolume) intent.putExtra("volume", encryptedVolume)
isStartingActivity = true
startActivity(intent) startActivity(intent)
} }
} }
@ -237,9 +224,9 @@ class ExplorerActivity : BaseExplorerActivity() {
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 {
val filePath = PathUtils.pathJoin(currentDirectoryPath, fileName) val filePath = PathUtils.pathJoin(currentDirectoryPath, fileName)
val handleID = encryptedVolume.openFile(filePath) val handleID = encryptedVolume.openFileWriteMode(filePath)
if (handleID == -1L) { if (handleID == -1L) {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.file_creation_failed) .setMessage(R.string.file_creation_failed)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@ -257,6 +244,7 @@ class ExplorerActivity : BaseExplorerActivity() {
val result = super.onCreateOptionsMenu(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) supportActionBar?.setDisplayHomeAsUpEnabled(true)
} else { } else {
@ -307,11 +295,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){
encryptedVolume.recursiveMapFiles(explorerElements[i].fullPath)?.forEach {
itemsToProcess.add(OperationFile.fromExplorerElement(it))
}
}
} }
currentItemAction = ItemsActions.COPY currentItemAction = ItemsActions.COPY
unselectAll() unselectAll()
@ -319,27 +302,32 @@ 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> {
taskScope.launch { val items = itemsToProcess.toMutableList()
val failedItem = fileOperationService.copyElements(it.toMutableList() as ArrayList<OperationFile>) itemsToProcess.filter { it.isDirectory }.forEach { dir ->
if (!isFinishing) { encryptedVolume.recursiveMapFiles(dir.srcPath)?.forEach {
if (failedItem == null) { items.add(OperationFile.fromExplorerElement(it))
Toast.makeText(this@ExplorerActivity, R.string.copy_success, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
} }
}
return items
}
}.startTask(lifecycleScope) { items ->
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() cancelItemAction()
invalidateOptionsMenu() invalidateOptionsMenu()
} }
}
} else if (currentItemAction == ItemsActions.MOVE){ } else if (currentItemAction == ItemsActions.MOVE){
itemsToProcess.forEach { itemsToProcess.forEach {
it.dstPath = PathUtils.pathJoin(currentDirectoryPath, it.name) it.dstPath = PathUtils.pathJoin(currentDirectoryPath, it.name)
@ -352,17 +340,12 @@ class ExplorerActivity : BaseExplorerActivity() {
toMove, toMove,
toClean, toClean,
) { ) {
taskScope.launch { activityScope.launch {
val failedItem = fileOperationService.moveElements(toMove, toClean) onTaskResult(
if (failedItem == null) { fileOperationService.moveElements(volumeId, toMove, toClean),
Toast.makeText(this@ExplorerActivity, R.string.move_success, Toast.LENGTH_SHORT).show() R.string.move_success,
} else { R.string.move_failed,
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue) )
.setTitle(R.string.error)
.setMessage(getString(R.string.move_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath)
} }
cancelItemAction() cancelItemAction()
@ -373,12 +356,13 @@ class ExplorerActivity : BaseExplorerActivity() {
} }
R.id.delete -> { R.id.delete -> {
val size = explorerAdapter.selectedItems.size val size = explorerAdapter.selectedItems.size
val dialog = CustomAlertDialogBuilder(this, themeValue) val dialog = CustomAlertDialogBuilder(this, theme)
dialog.setTitle(R.string.warning) dialog.setTitle(R.string.warning)
dialog.setPositiveButton(R.string.ok) { _, _ -> dialog.setPositiveButton(R.string.ok) { _, _ ->
taskScope.launch { val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
fileOperationService.removeElements(explorerAdapter.selectedItems.map { i -> explorerElements[i] })?.let { failedItem -> activityScope.launch {
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue) fileOperationService.removeElements(volumeId, items)?.let { failedItem ->
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, failedItem)) .setMessage(getString(R.string.remove_failed, failedItem))
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@ -401,17 +385,30 @@ class ExplorerActivity : BaseExplorerActivity() {
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, themeValue, encryptedVolume, paths)
unselectAll() unselectAll()
true true
} }
R.id.decrypt -> { R.id.decrypt -> {
isStartingActivity = true app.isStartingExternalApp = true
pickExportDirectory.launch(null) pickExportDirectory.launch(null)
true true
} }
@ -427,7 +424,7 @@ class ExplorerActivity : BaseExplorerActivity() {
private fun checkMoveOverwrite(items: List<OperationFile>, callback: (List<OperationFile>?) -> Unit) { private fun checkMoveOverwrite(items: List<OperationFile>, callback: (List<OperationFile>?) -> Unit) {
for (item in items) { for (item in items) {
if (encryptedVolume.pathExists(item.dstPath!!) && !item.overwriteConfirmed) { if (encryptedVolume.pathExists(item.dstPath!!) && !item.overwriteConfirmed) {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setMessage( .setMessage(
getString( getString(
@ -506,13 +503,4 @@ class ExplorerActivity : BaseExplorerActivity() {
itemsToProcess.clear() itemsToProcess.clear()
} }
} }
override fun onBackPressed() {
if (currentItemAction != ItemsActions.NONE) {
cancelItemAction()
invalidateOptionsMenu()
} else {
super.onBackPressed()
}
}
} }

View File

@ -61,7 +61,7 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
getString(R.string.share_intent_parsing_failed) getString(R.string.share_intent_parsing_failed)
} }
errorMsg?.let { errorMsg?.let {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(it) .setMessage(it)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
@ -73,23 +73,15 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
} }
} }
private fun onImported(failedItem: String?){ private fun onImported() {
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath)
if (failedItem == null) { CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.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 {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
} }
} }

View File

@ -14,7 +14,7 @@ class ExplorerActivityPick : BaseExplorerActivity() {
private var isFinishingIntentionally = false private var isFinishingIntentionally = false
override fun init() { override fun init() {
setContentView(R.layout.activity_explorer_pick) setContentView(R.layout.activity_explorer_pick)
resultIntent.putExtra("volume", encryptedVolume) resultIntent.putExtra("volumeId", volumeId)
} }
override fun bindFileOperationService() { override fun bindFileOperationService() {
@ -22,9 +22,7 @@ class ExplorerActivityPick : BaseExplorerActivity() {
} }
override fun onExplorerElementClick(position: Int) { override fun onExplorerElementClick(position: Int) {
val wasSelecting = explorerAdapter.selectedItems.isNotEmpty()
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 -> {
@ -39,7 +37,6 @@ class ExplorerActivityPick : BaseExplorerActivity() {
} }
} }
} }
}
invalidateOptionsMenu() invalidateOptionsMenu()
} }
@ -81,17 +78,4 @@ class ExplorerActivityPick : BaseExplorerActivity() {
isFinishingIntentionally = true isFinishingIntentionally = true
finish() finish()
} }
override fun closeVolumeOnDestroy() {
if (!isFinishingIntentionally && !usf_keep_open){
IntentUtils.getParcelableExtra<EncryptedVolume>(intent, "destinationVolume")?.close()
super.closeVolumeOnDestroy()
}
}
override fun closeVolumeOnUserExit() {
isFinishingIntentionally = true
super.closeVolumeOnUserExit()
super.closeVolumeOnDestroy()
}
} }

View File

@ -6,7 +6,7 @@ import sushi.hardcore.droidfs.util.PathUtils
import java.text.Collator import java.text.Collator
class ExplorerElement(val name: String, val stat: Stat, val parentPath: String) { class ExplorerElement(val name: String, val stat: Stat, val parentPath: String) {
val fullPath: String = PathUtils.pathJoin(parentPath, name) val fullPath: String = PathUtils.pathJoin(parentPath.ifEmpty { "/" }, name)
val collationKey = Collator.getInstance().getCollationKeyForFileName(fullPath) val collationKey = Collator.getInstance().getCollationKeyForFileName(fullPath)
val isDirectory: Boolean val isDirectory: Boolean

View File

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

View File

@ -13,9 +13,20 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.* import kotlinx.coroutines.CancellationException
import sushi.hardcore.droidfs.ConstValues import kotlinx.coroutines.CoroutineScope
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.withContext
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.Constants
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.EncryptedVolume
import sushi.hardcore.droidfs.util.ObjRef import sushi.hardcore.droidfs.util.ObjRef
@ -31,19 +42,18 @@ class FileOperationService : Service() {
} }
private val binder = LocalBinder() private val binder = LocalBinder()
private lateinit var encryptedVolume: EncryptedVolume private lateinit var volumeManger: VolumeManager
private var serviceScope = MainScope()
private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationManager: NotificationManagerCompat
private val tasks = HashMap<Int, Job>() private val tasks = HashMap<Int, Job>()
private var lastNotificationId = 0 private var lastNotificationId = 0
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
fun getService(): FileOperationService = this@FileOperationService fun getService(): FileOperationService = this@FileOperationService
fun setEncryptedVolume(volume: EncryptedVolume) {
encryptedVolume = volume
}
} }
override fun onBind(p0: Intent?): IBinder { override fun onBind(p0: Intent?): IBinder {
volumeManger = (application as VolumeManagerApp).volumeManager
return binder return binder
} }
@ -64,7 +74,7 @@ class FileOperationService : Service() {
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
notificationBuilder notificationBuilder
.setContentTitle(getString(message)) .setContentTitle(getString(message))
.setSmallIcon(R.mipmap.icon_launcher) .setSmallIcon(R.drawable.ic_notification)
.setOngoing(true) .setOngoing(true)
.addAction(NotificationCompat.Action( .addAction(NotificationCompat.Action(
R.drawable.icon_close, R.drawable.icon_close,
@ -79,7 +89,7 @@ class FileOperationService : Service() {
putExtra("bundle", bundle) putExtra("bundle", bundle)
action = ACTION_CANCEL action = ACTION_CANCEL
}, },
PendingIntent.FLAG_UPDATE_CURRENT PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
)) ))
if (total != null) { if (total != null) {
@ -110,29 +120,57 @@ class FileOperationService : Service() {
tasks[notificationId]?.cancel() tasks[notificationId]?.cancel()
} }
class TaskResult<T>(val cancelled: Boolean, val failedItem: T?) private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
}
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<T> { private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<out T> {
tasks[notification.notificationId] = task tasks[notification.notificationId] = task
return try { return coroutineScope {
TaskResult(false, task.await()) withContext(serviceScope.coroutineContext) {
try {
TaskResult.completed(task.await())
} catch (e: CancellationException) { } catch (e: CancellationException) {
TaskResult(true, null) TaskResult.cancelled()
} catch (e: Throwable) {
e.printStackTrace()
TaskResult.error(e.localizedMessage)
} finally { } finally {
cancelNotification(notification) cancelNotification(notification)
} }
} }
}
}
private fun copyFile(srcPath: String, dstPath: String, remoteEncryptedVolume: EncryptedVolume = encryptedVolume): Boolean { private suspend fun <T> volumeTask(
volumeId: Int,
notification: FileOperationNotification,
task: suspend (encryptedVolume: EncryptedVolume) -> T
): TaskResult<out T> {
return waitForTask(
notification,
volumeManger.getCoroutineScope(volumeId).async {
task(getEncryptedVolume(volumeId))
}
)
}
private suspend fun copyFile(
encryptedVolume: EncryptedVolume,
srcPath: String,
dstPath: String,
srcEncryptedVolume: EncryptedVolume = encryptedVolume,
): Boolean {
var success = true var success = true
val srcFileHandle = remoteEncryptedVolume.openFile(srcPath) val srcFileHandle = srcEncryptedVolume.openFileReadMode(srcPath)
if (srcFileHandle != -1L) { if (srcFileHandle != -1L) {
val dstFileHandle = encryptedVolume.openFile(dstPath) val dstFileHandle = encryptedVolume.openFileWriteMode(dstPath)
if (dstFileHandle != -1L) { if (dstFileHandle != -1L) {
var offset: Long = 0 var offset: Long = 0
val ioBuffer = ByteArray(ConstValues.IO_BUFF_SIZE) val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
var length: Long var length: Long
while (remoteEncryptedVolume.read(srcFileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it.toLong() } > 0) { while (srcEncryptedVolume.read(srcFileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it.toLong() } > 0) {
yield()
val written = encryptedVolume.write(dstFileHandle, offset, ioBuffer, 0, length).toLong() val written = encryptedVolume.write(dstFileHandle, offset, ioBuffer, 0, length).toLong()
if (written == length) { if (written == length) {
offset += written offset += written
@ -146,7 +184,7 @@ class FileOperationService : Service() {
} else { } else {
success = false success = false
} }
remoteEncryptedVolume.closeFile(srcFileHandle) srcEncryptedVolume.closeFile(srcFileHandle)
} else { } else {
success = false success = false
} }
@ -154,26 +192,25 @@ class FileOperationService : Service() {
} }
suspend fun copyElements( suspend fun copyElements(
items: ArrayList<OperationFile>, volumeId: Int,
remoteEncryptedVolume: EncryptedVolume = encryptedVolume items: List<OperationFile>,
): String? = coroutineScope { srcVolumeId: Int = volumeId,
): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_copy_msg, items.size) val notification = showNotification(R.string.file_op_copy_msg, items.size)
val task = async { val srcEncryptedVolume = getEncryptedVolume(srcVolumeId)
return volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null var failedItem: String? = null
for (i in 0 until items.size) { for (i in items.indices) {
withContext(Dispatchers.IO) { yield()
if (items[i].isDirectory) { if (items[i].isDirectory) {
if (!encryptedVolume.pathExists(items[i].dstPath!!)) { if (!encryptedVolume.pathExists(items[i].dstPath!!)) {
if (!encryptedVolume.mkdir(items[i].dstPath!!)) { if (!encryptedVolume.mkdir(items[i].dstPath!!)) {
failedItem = items[i].srcPath failedItem = items[i].srcPath
} }
} }
} else { } else if (!copyFile(encryptedVolume, items[i].srcPath, items[i].dstPath!!, srcEncryptedVolume)) {
if (!copyFile(items[i].srcPath, items[i].dstPath!!, remoteEncryptedVolume)) {
failedItem = items[i].srcPath failedItem = items[i].srcPath
} }
}
}
if (failedItem == null) { if (failedItem == null) {
updateNotificationProgress(notification, i+1, items.size) updateNotificationProgress(notification, i+1, items.size)
} else { } else {
@ -182,13 +219,11 @@ class FileOperationService : Service() {
} }
failedItem failedItem
} }
// treat cancellation as success
waitForTask(notification, task).failedItem
} }
suspend fun moveElements(toMove: List<OperationFile>, toClean: List<String>): String? = coroutineScope { suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_move_msg, toMove.size) val notification = showNotification(R.string.file_op_move_msg, toMove.size)
val task = async(Dispatchers.IO) { return volumeTask(volumeId, notification) { encryptedVolume ->
val total = toMove.size+toClean.size val total = toMove.size+toClean.size
var failedItem: String? = null var failedItem: String? = null
for ((i, item) in toMove.withIndex()) { for ((i, item) in toMove.withIndex()) {
@ -211,18 +246,17 @@ class FileOperationService : Service() {
} }
failedItem failedItem
} }
// treat cancellation as success
waitForTask(notification, task).failedItem
} }
private suspend fun importFilesFromUris( private suspend fun importFilesFromUris(
encryptedVolume: EncryptedVolume,
dstPaths: List<String>, dstPaths: List<String>,
uris: List<Uri>, uris: List<Uri>,
notification: FileOperationNotification, notification: FileOperationNotification,
): String? { ): String? {
var failedIndex = -1 var failedIndex = -1
for (i in dstPaths.indices) { for (i in dstPaths.indices) {
withContext(Dispatchers.IO) { yield()
try { try {
if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) { if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
failedIndex = i failedIndex = i
@ -230,7 +264,6 @@ class FileOperationService : Service() {
} catch (e: FileNotFoundException) { } catch (e: FileNotFoundException) {
failedIndex = i failedIndex = i
} }
}
if (failedIndex == -1) { if (failedIndex == -1) {
updateNotificationProgress(notification, i+1, dstPaths.size) updateNotificationProgress(notification, i+1, dstPaths.size)
} else { } else {
@ -240,12 +273,11 @@ class FileOperationService : Service() {
return null return null
} }
suspend fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>): TaskResult<String?> = coroutineScope { suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_import_msg, dstPaths.size) val notification = showNotification(R.string.file_op_import_msg, dstPaths.size)
val task = async { return volumeTask(volumeId, notification) { encryptedVolume ->
importFilesFromUris(dstPaths, uris, notification) importFilesFromUris(encryptedVolume, dstPaths, uris, notification)
} }
waitForTask(notification, task)
} }
/** /**
@ -255,53 +287,42 @@ class FileOperationService : Service() {
* *
* @return false if cancelled early, true otherwise. * @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>,
scope: CoroutineScope, ) {
): Boolean {
dstDirs.add(rootDstPath) dstDirs.add(rootDstPath)
for (child in rootSrcDir.listFiles()) { for (child in rootSrcDir.listFiles()) {
if (!scope.isActive) { yield()
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, scope)) { 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
} }
class ImportDirectoryResult(val taskResult: TaskResult<String?>, val uris: List<Uri>) class ImportDirectoryResult(val taskResult: TaskResult<out String?>, val uris: List<Uri>)
suspend fun importDirectory( suspend fun importDirectory(
volumeId: Int,
rootDstPath: String, rootDstPath: String,
rootSrcDir: DocumentFile, rootSrcDir: DocumentFile,
): ImportDirectoryResult = coroutineScope { ): ImportDirectoryResult {
val notification = showNotification(R.string.file_op_import_msg, null) val notification = showNotification(R.string.file_op_import_msg, null)
val srcUris = arrayListOf<Uri>() val srcUris = arrayListOf<Uri>()
val task = async { return ImportDirectoryResult(volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null var failedItem: String? = null
val dstFiles = arrayListOf<String>() val dstFiles = arrayListOf<String>()
val dstDirs = arrayListOf<String>() val dstDirs = arrayListOf<String>()
recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs)
withContext(Dispatchers.IO) {
if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, this)) {
return@withContext
}
// 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 (!encryptedVolume.mkdir(dir)) { if (!encryptedVolume.mkdir(dir)) {
@ -309,23 +330,20 @@ class FileOperationService : Service() {
break break
} }
} }
}
if (failedItem == null) { if (failedItem == null) {
failedItem = importFilesFromUris(dstFiles, srcUris, notification) failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, notification)
} }
failedItem failedItem
} }, srcUris)
ImportDirectoryResult(waitForTask(notification, task), srcUris)
} }
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): String? = coroutineScope { suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_wiping_msg, uris.size) val notification = showNotification(R.string.file_op_wiping_msg, uris.size)
val task = async { val task = serviceScope.async(Dispatchers.IO) {
var errorMsg: String? = null var errorMsg: String? = null
for (i in uris.indices) { for (i in uris.indices) {
withContext(Dispatchers.IO) { yield()
errorMsg = Wiper.wipe(this@FileOperationService, uris[i]) errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
}
if (errorMsg == null) { if (errorMsg == null) {
updateNotificationProgress(notification, i+1, uris.size) updateNotificationProgress(notification, i+1, uris.size)
} else { } else {
@ -337,11 +355,10 @@ class FileOperationService : Service() {
} }
errorMsg errorMsg
} }
// treat cancellation as success return waitForTask(notification, task)
waitForTask(notification, task).failedItem
} }
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)
} }
@ -352,44 +369,41 @@ class FileOperationService : Service() {
} }
} }
private fun recursiveExportDirectory( private suspend fun recursiveExportDirectory(
encryptedVolume: EncryptedVolume,
plain_directory_path: String, plain_directory_path: String,
treeDocumentFile: DocumentFile, treeDocumentFile: DocumentFile,
scope: CoroutineScope
): String? { ): String? {
treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree -> treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree ->
val explorerElements = encryptedVolume.readDir(plain_directory_path) ?: return null val explorerElements = encryptedVolume.readDir(plain_directory_path) ?: return null
for (e in explorerElements) { for (e in explorerElements) {
if (!scope.isActive) { yield()
return null
}
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, scope) recursiveExportDirectory(encryptedVolume, fullPath, childTree)?.let { return it }
failedItem?.let { return it } } else if (!exportFileInto(encryptedVolume, fullPath, childTree)) {
} else {
if (!exportFileInto(fullPath, childTree)){
return fullPath return fullPath
} }
} }
}
return null return null
} }
return treeDocumentFile.name return treeDocumentFile.name
} }
suspend fun exportFiles(uri: Uri, items: List<ExplorerElement>): TaskResult<String?> = coroutineScope { suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_export_msg, items.size) val notification = showNotification(R.string.file_op_export_msg, items.size)
val task = async { return volumeTask(volumeId, notification) { encryptedVolume ->
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!! val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
var failedItem: String? = null var failedItem: String? = null
for (i in items.indices) { for (i in items.indices) {
withContext(Dispatchers.IO) { yield()
failedItem = if (items[i].isDirectory) { failedItem = if (items[i].isDirectory) {
recursiveExportDirectory(items[i].fullPath, treeDocumentFile, this) recursiveExportDirectory(encryptedVolume, items[i].fullPath, treeDocumentFile)
} else { } else {
if (exportFileInto(items[i].fullPath, treeDocumentFile)) null else items[i].fullPath if (exportFileInto(encryptedVolume, items[i].fullPath, treeDocumentFile)) {
null
} else {
items[i].fullPath
} }
} }
if (failedItem == null) { if (failedItem == null) {
@ -400,22 +414,38 @@ class FileOperationService : Service() {
} }
failedItem failedItem
} }
waitForTask(notification, task)
} }
suspend fun removeElements(items: List<ExplorerElement>): String? = coroutineScope { 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? {
val notification = showNotification(R.string.file_op_delete_msg, items.size) val notification = showNotification(R.string.file_op_delete_msg, items.size)
val task = async(Dispatchers.IO) { return volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null var failedItem: String? = null
for ((i, element) in items.withIndex()) { for ((i, element) in items.withIndex()) {
yield()
if (element.isDirectory) { if (element.isDirectory) {
val result = encryptedVolume.recursiveRemoveDirectory(element.fullPath) recursiveRemoveDirectory(encryptedVolume, element.fullPath)?.let { failedItem = it }
result?.let { failedItem = it } } else if (!encryptedVolume.deleteFile(element.fullPath)) {
} else {
if (!encryptedVolume.deleteFile(element.fullPath)) {
failedItem = element.fullPath failedItem = element.fullPath
} }
}
if (failedItem == null) { if (failedItem == null) {
updateNotificationProgress(notification, i + 1, items.size) updateNotificationProgress(notification, i + 1, items.size)
} else { } else {
@ -423,14 +453,11 @@ class FileOperationService : Service() {
} }
} }
failedItem failedItem
} }.failedItem // treat cancellation as success
waitForTask(notification, task).failedItem
} }
private fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int { private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int {
if (!scope.isActive) { yield()
return 0
}
val children = rootDirectory.listFiles() val children = rootDirectory.listFiles()
var count = children.size var count = children.size
for (child in children) { for (child in children) {
@ -441,7 +468,7 @@ class FileOperationService : Service() {
return count return count
} }
private fun recursiveCopyVolume( private suspend fun recursiveCopyVolume(
src: DocumentFile, src: DocumentFile,
dst: DocumentFile, dst: DocumentFile,
dstRootDirectory: ObjRef<DocumentFile?>?, dstRootDirectory: ObjRef<DocumentFile?>?,
@ -453,9 +480,7 @@ class FileOperationService : Service() {
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
dstRootDirectory?.let { it.value = dstDir } dstRootDirectory?.let { it.value = dstDir }
for (child in src.listFiles()) { for (child in src.listFiles()) {
if (!scope.isActive) { yield()
return null
}
if (child.isFile) { if (child.isFile) {
val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child
val outputStream = contentResolver.openOutputStream(dstFile.uri) val outputStream = contentResolver.openOutputStream(dstFile.uri)
@ -474,21 +499,16 @@ class FileOperationService : Service() {
return null return null
} }
class CopyVolumeResult(val taskResult: TaskResult<DocumentFile?>, val dstRootDirectory: DocumentFile?) class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult = coroutineScope { suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
val notification = showNotification(R.string.copy_volume_notification, null) val notification = showNotification(R.string.copy_volume_notification, null)
val dstRootDirectory = ObjRef<DocumentFile?>(null) val dstRootDirectory = ObjRef<DocumentFile?>(null)
val task = async(Dispatchers.IO) { val task = serviceScope.async(Dispatchers.IO) {
val total = recursiveCountChildElements(src, this) val total = recursiveCountChildElements(src, this)
if (isActive) {
updateNotificationProgress(notification, 0, total) updateNotificationProgress(notification, 0, total)
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this) recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this)
} else {
null
} }
} return CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
// treat cancellation as success
CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
} }
} }

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,8 +1,11 @@
package sushi.hardcore.droidfs.file_viewers package sushi.hardcore.droidfs.file_viewers
import com.google.android.exoplayer2.ExoPlayer 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
@OptIn(UnstableApi::class)
class AudioPlayer: MediaPlayer(){ class AudioPlayer: MediaPlayer(){
private lateinit var binding: ActivityAudioPlayerBinding private lateinit var binding: ActivityAudioPlayerBinding

View File

@ -1,21 +1,25 @@
package sushi.hardcore.droidfs.file_viewers package sushi.hardcore.droidfs.file_viewers
import android.net.Uri import android.net.Uri
import com.google.android.exoplayer2.C import androidx.media3.common.C
import com.google.android.exoplayer2.upstream.DataSource import androidx.annotation.OptIn
import com.google.android.exoplayer2.upstream.DataSpec import androidx.media3.common.util.UnstableApi
import com.google.android.exoplayer2.upstream.TransferListener import androidx.media3.datasource.DataSource
import sushi.hardcore.droidfs.ConstValues import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import kotlin.math.min import kotlin.math.min
class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, private val filePath: String): DataSource { @OptIn(UnstableApi::class)
class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, private val filePath: String):
DataSource {
private var fileHandle = -1L private var fileHandle = -1L
private var fileOffset: Long = 0 private var fileOffset: Long = 0
private var bytesRemaining: Long = -1 private var bytesRemaining: Long = -1
override fun open(dataSpec: DataSpec): Long { override fun open(dataSpec: DataSpec): Long {
fileHandle = encryptedVolume.openFile(filePath) fileHandle = encryptedVolume.openFileReadMode(filePath)
fileOffset = dataSpec.position fileOffset = dataSpec.position
val fileSize = encryptedVolume.getAttr(filePath)!!.size val fileSize = encryptedVolume.getAttr(filePath)!!.size
bytesRemaining = if (dataSpec.length == C.LENGTH_UNSET.toLong()) { bytesRemaining = if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
@ -27,7 +31,7 @@ class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, pr
} }
override fun getUri(): Uri { override fun getUri(): Uri {
return ConstValues.FAKE_URI return Constants.FAKE_URI
} }
override fun close() { override fun close() {
@ -50,7 +54,11 @@ class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, pr
) {} ) {}
val totalRead = fileOffset-originalOffset val totalRead = fileOffset-originalOffset
bytesRemaining -= totalRead bytesRemaining -= totalRead
return totalRead.toInt() return if (totalRead == 0L) {
C.RESULT_END_OF_INPUT
} else {
totalRead.toInt()
}
} }
class Factory(private val encryptedVolume: EncryptedVolume, private val filePath: String): DataSource.Factory { class Factory(private val encryptedVolume: EncryptedVolume, private val filePath: String): DataSource.Factory {

View File

@ -1,20 +1,23 @@
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.View
import androidx.activity.addCallback import android.view.WindowInsets
import androidx.core.content.ContextCompat 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 androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.IntentUtils import sushi.hardcore.droidfs.util.IntentUtils
@ -27,65 +30,77 @@ abstract class FileViewerActivity: BaseActivity() {
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
protected open var fullscreenMode = true private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
private val legacyMod by lazy {
sharedPrefs.getBoolean("legacyMod", false)
}
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)
encryptedVolume = IntentUtils.getParcelableExtra(intent, "volume")!! encryptedVolume = IntentUtils.getParcelableExtra(intent, "volume")!!
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false)
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
} }
onBackPressedDispatcher.addCallback(this) { windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
isFinishingIntentionally = true
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
if (fullscreenMode) {
fixNavBarColor()
hideSystemUi()
}
viewFile() viewFile()
} }
private fun fixNavBarColor() { open fun showPartialSystemUi() {
window.navigationBarColor = ContextCompat.getColor(this, R.color.fullScreenBackgroundColor) 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())
}
} }
private fun hideSystemUi() { open fun hideSystemUi() {
if (legacyMod) { if (isLegacyFullscreen) {
@Suppress("Deprecation") @Suppress("Deprecation")
window.decorView.systemUiVisibility = window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_LOW_PROFILE or View.SYSTEM_UI_FLAG_LOW_PROFILE or
View.SYSTEM_UI_FLAG_FULLSCREEN View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
} else { } else {
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) 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() {
super.onUserInteraction()
if (fullscreenMode && windowTypeMask and WindowInsetsCompat.Type.statusBars() == 0) {
hideSystemUi()
}
}
protected fun loadWholeFile(path: String, fileSize: Long? = null, callback: (ByteArray) -> Unit) { protected fun loadWholeFile(path: String, fileSize: Long? = null, callback: (ByteArray) -> Unit) {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val result = encryptedVolume.loadWholeFile(path, size = fileSize) val result = encryptedVolume.loadWholeFile(path, size = fileSize)
@ -94,7 +109,7 @@ abstract class FileViewerActivity: BaseActivity() {
if (result.second == 0) { if (result.second == 0) {
callback(result.first!!) callback(result.first!!)
} else { } else {
val dialog = CustomAlertDialogBuilder(this@FileViewerActivity, themeValue) val dialog = CustomAlertDialogBuilder(this@FileViewerActivity, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setCancelable(false) .setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() } .setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() }
@ -116,7 +131,7 @@ abstract class FileViewerActivity: BaseActivity() {
encryptedVolume.recursiveMapFiles(originalParentPath)?.let { elements -> encryptedVolume.recursiveMapFiles(originalParentPath)?.let { elements ->
for (e in elements) { for (e in elements) {
if (e.isRegularFile) { if (e.isRegularFile) {
if (ConstValues.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) { if (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) {
mappedPlaylist.add(e) mappedPlaylist.add(e)
} }
} }
@ -156,21 +171,12 @@ abstract class FileViewerActivity: BaseActivity() {
} }
protected fun goBackToExplorer() { protected fun goBackToExplorer() {
isFinishingIntentionally = true
finish() finish()
} }
override fun onDestroy() { override fun onResume() {
super.onDestroy() super.onResume()
if (!isFinishingIntentionally) { if (encryptedVolume.isClosed()) {
encryptedVolume.close()
RestrictedFileProvider.wipeAll(this)
}
}
override fun onPause() {
super.onPause()
if (!usf_keep_open) {
finish() finish()
} }
} }

View File

@ -10,11 +10,13 @@ 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.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.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
@ -31,18 +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 val imageViewModel: ImageViewModel by viewModels()
private var requestBuilder: RequestBuilder<Drawable>? = null 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 originalOrientation: Float = 0f
private var rotationAngle: Float = 0F
private var orientationTransformation: OrientationTransformation? = null private var orientationTransformation: OrientationTransformation? = null
private val hideUI = Runnable { private val hideUI = Runnable {
binding.actionButtons.visibility = View.GONE binding.actionButtons.visibility = View.GONE
binding.topBar.visibility = View.GONE binding.topBar.visibility = View.GONE
hideSystemUi()
} }
private val slideshowNext = Runnable { private val slideshowNext = Runnable {
if (slideshowActive){ if (slideshowActive){
@ -60,6 +67,8 @@ class ImageViewer: FileViewerActivity() {
binding = ActivityImageViewerBinding.inflate(layoutInflater) binding = ActivityImageViewerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
supportActionBar?.hide() 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?) {
@ -67,6 +76,7 @@ class ImageViewer: FileViewerActivity() {
if (binding.actionButtons.visibility == View.GONE) { if (binding.actionButtons.visibility == View.GONE) {
binding.actionButtons.visibility = View.VISIBLE binding.actionButtons.visibility = View.VISIBLE
binding.topBar.visibility = View.VISIBLE binding.topBar.visibility = View.VISIBLE
showPartialSystemUi()
handler.postDelayed(hideUI, hideDelay) handler.postDelayed(hideUI, hideDelay)
} else { } else {
hideUI.run() hideUI.run()
@ -91,7 +101,7 @@ class ImageViewer: FileViewerActivity() {
} }
}) })
binding.imageDelete.setOnClickListener { binding.imageDelete.setOnClickListener {
CustomAlertDialogBuilder(this, themeValue) CustomAlertDialogBuilder(this, theme)
.keepFullScreen() .keepFullScreen()
.setTitle(R.string.warning) .setTitle(R.string.warning)
.setPositiveButton(R.string.ok) { _, _ -> .setPositiveButton(R.string.ok) { _, _ ->
@ -102,10 +112,10 @@ class ImageViewer: FileViewerActivity() {
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 {
CustomAlertDialogBuilder(this, themeValue) 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))
@ -120,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()
@ -140,14 +150,8 @@ class ImageViewer: FileViewerActivity() {
swipeImage(-1F) swipeImage(-1F)
} }
} }
binding.imageRotateRight.setOnClickListener { binding.imageRotateRight.setOnClickListener { onClickRotate(90f) }
rotationAngle += 90 binding.imageRotateLeft.setOnClickListener { onClickRotate(-90f) }
rotateImage()
}
binding.imageRotateLeft.setOnClickListener {
rotationAngle -= 90
rotateImage()
}
onBackPressedDispatcher.addCallback(this) { onBackPressedDispatcher.addCallback(this) {
if (slideshowActive) { if (slideshowActive) {
stopSlideshow() stopSlideshow()
@ -158,19 +162,27 @@ class ImageViewer: FileViewerActivity() {
} }
} }
} }
loadImage() loadImage(false)
handler.postDelayed(hideUI, hideDelay) handler.postDelayed(hideUI, hideDelay)
} }
private fun loadImage(){ private fun loadImage(newImage: Boolean) {
fileName = File(filePath).name fileName = File(filePath).name
binding.textFilename.text = fileName binding.textFilename.text = fileName
requestBuilder = null if (newImage || imageViewModel.imageBytes == null) {
loadWholeFile(filePath) { loadWholeFile(filePath) {
originalOrientation = 0f imageViewModel.imageBytes = it
requestBuilder = Glide.with(this).load(it) requestBuilder = Glide.with(this).load(it)
requestBuilder?.into(binding.imageViewer) requestBuilder?.into(binding.imageViewer)
rotationAngle = originalOrientation imageViewModel.rotationAngle = 0f
}
} else {
requestBuilder = Glide.with(this).load(imageViewModel.imageBytes)
if (imageViewModel.rotationAngle.mod(360f) != 0f) {
rotateImage()
} else {
requestBuilder?.into(binding.imageViewer)
}
} }
} }
@ -180,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)
} }
} }
@ -214,15 +232,14 @@ class ImageViewer: FileViewerActivity() {
} }
} }
private fun rotateImage(){ private fun rotateImage() {
binding.imageViewer.restoreZoomNormal() orientationTransformation = OrientationTransformation(imageViewModel.rotationAngle)
orientationTransformation = OrientationTransformation(rotationAngle)
requestBuilder?.transform(orientationTransformation)?.into(binding.imageViewer) requestBuilder?.transform(orientationTransformation)?.into(binding.imageViewer)
} }
private fun askSaveRotation(callback: () -> Unit){ private fun askSaveRotation(callback: () -> Unit){
if (rotationAngle.mod(360f) != originalOrientation && !slideshowActive) { if (imageViewModel.rotationAngle.mod(360f) != 0f && !slideshowActive) {
CustomAlertDialogBuilder(this, themeValue) 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)
@ -235,13 +252,13 @@ class ImageViewer: FileViewerActivity() {
Bitmap.CompressFormat.PNG Bitmap.CompressFormat.PNG
} else { } else {
Bitmap.CompressFormat.JPEG Bitmap.CompressFormat.JPEG
}, 100, outputStream) == true }, 90, outputStream) == true
){ ){
if (encryptedVolume.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 {
CustomAlertDialogBuilder(this, themeValue) 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)
@ -249,7 +266,7 @@ class ImageViewer: FileViewerActivity() {
.show() .show()
} }
} else { } else {
CustomAlertDialogBuilder(this, themeValue) 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,16 +1,22 @@
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.DefaultExtractorsFactory import androidx.media3.common.MediaItem
import com.google.android.exoplayer2.source.MediaSource import androidx.media3.common.PlaybackException
import com.google.android.exoplayer2.source.ProgressiveMediaSource import androidx.media3.common.Player
import com.google.android.exoplayer2.video.VideoSize import androidx.media3.common.VideoSize
import sushi.hardcore.droidfs.ConstValues import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import java.io.File
@OptIn(UnstableApi::class)
abstract class MediaPlayer: FileViewerActivity() { abstract class MediaPlayer: FileViewerActivity() {
private lateinit var player: ExoPlayer private lateinit var player: ExoPlayer
@ -27,7 +33,7 @@ abstract class MediaPlayer: FileViewerActivity() {
private fun createMediaSource(filePath: String): MediaSource { private fun createMediaSource(filePath: String): MediaSource {
val dataSourceFactory = EncryptedVolumeDataSource.Factory(encryptedVolume, filePath) val dataSourceFactory = EncryptedVolumeDataSource.Factory(encryptedVolume, filePath)
return ProgressiveMediaSource.Factory(dataSourceFactory, DefaultExtractorsFactory()) return ProgressiveMediaSource.Factory(dataSourceFactory, DefaultExtractorsFactory())
.createMediaSource(MediaItem.fromUri(ConstValues.FAKE_URI)) .createMediaSource(MediaItem.fromUri(Constants.FAKE_URI))
} }
private fun initializePlayer(){ private fun initializePlayer(){
@ -45,7 +51,7 @@ abstract class MediaPlayer: FileViewerActivity() {
onVideoSizeChanged(videoSize.width, videoSize.height) onVideoSizeChanged(videoSize.width, videoSize.height)
} }
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
CustomAlertDialogBuilder(this@MediaPlayer, themeValue) 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)
@ -61,9 +67,11 @@ abstract class MediaPlayer: FileViewerActivity() {
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex+1) % mappedPlaylist.size) if (player.repeatMode != Player.REPEAT_MODE_ONE) {
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % mappedPlaylist.size)
refreshFileName() refreshFileName()
} }
}
}) })
player.prepare() player.prepare()
} }

View File

@ -10,7 +10,6 @@ class PdfViewer: FileViewerActivity() {
init { init {
applyCustomTheme = false applyCustomTheme = false
} }
override var fullscreenMode = false
private lateinit var pdfViewer: PdfViewer private lateinit var pdfViewer: PdfViewer
override fun getFileType(): String { override fun getFileType(): String {
@ -27,7 +26,7 @@ class PdfViewer: FileViewerActivity() {
} }
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
pdfViewer.onCreateOptionMenu(menu) pdfViewer.onCreateOptionMenu(menu)
return super.onCreateOptionsMenu(menu) return super.onCreateOptionsMenu(menu)
} }
@ -42,7 +41,7 @@ class PdfViewer: FileViewerActivity() {
pdfViewer.onDestroy() pdfViewer.onDestroy()
} }
override fun onPrepareOptionsMenu(menu: Menu?): Boolean { override fun onPrepareOptionsMenu(menu: Menu): Boolean {
return pdfViewer.onPrepareOptionsMenu(menu) return pdfViewer.onPrepareOptionsMenu(menu)
} }

View File

@ -7,12 +7,12 @@ import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.widget.EditText import android.widget.EditText
import android.widget.Toast import android.widget.Toast
import androidx.activity.addCallback
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import java.io.File
class TextEditor: FileViewerActivity() { class TextEditor: FileViewerActivity() {
override var fullscreenMode = false
private lateinit var fileName: String private lateinit var fileName: String
private lateinit var editor: EditText private lateinit var editor: EditText
private var changedSinceLastSave = false private var changedSinceLastSave = false
@ -29,8 +29,11 @@ class TextEditor: FileViewerActivity() {
loadWholeFile(filePath) { loadWholeFile(filePath) {
try { try {
loadLayout(String(it)) loadLayout(String(it))
onBackPressedDispatcher.addCallback(this) {
checkSaveAndExit()
}
} catch (e: OutOfMemoryError){ } catch (e: OutOfMemoryError){
CustomAlertDialogBuilder(this, themeValue) 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)
@ -64,7 +67,7 @@ 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 fileHandle = encryptedVolume.openFile(filePath) val fileHandle = encryptedVolume.openFileWriteMode(filePath)
if (fileHandle != -1L) { if (fileHandle != -1L) {
var offset: Long = 0 var offset: Long = 0
while (offset < content.size && encryptedVolume.write(fileHandle, offset, content, offset, content.size.toLong()).also { offset += it } > 0) {} while (offset < content.size && encryptedVolume.write(fileHandle, offset, content, offset, content.size.toLong()).also { offset += it } > 0) {}
@ -83,7 +86,7 @@ class TextEditor: FileViewerActivity() {
private fun checkSaveAndExit(){ private fun checkSaveAndExit(){
if (changedSinceLastSave){ if (changedSinceLastSave){
CustomAlertDialogBuilder(this, themeValue) 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) { _, _ ->
@ -124,8 +127,4 @@ class TextEditor: FileViewerActivity() {
} }
return true return true
} }
override fun onBackPressed() {
checkSaveAndExit()
}
} }

View File

@ -2,8 +2,9 @@ package sushi.hardcore.droidfs.file_viewers
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import com.google.android.exoplayer2.ExoPlayer import android.view.View
import com.google.android.exoplayer2.ui.StyledPlayerView 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() {
@ -16,9 +17,15 @@ 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.doubleTapOverlay = binding.doubleTapOverlay
binding.videoPlayer.setControllerVisibilityListener(StyledPlayerView.ControllerVisibilityListener { visibility -> binding.videoPlayer.setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { visibility ->
binding.topBar.visibility = visibility binding.topBar.visibility = visibility
if (visibility == View.VISIBLE) {
showPartialSystemUi()
} else {
hideSystemUi()
}
}) })
binding.rotateButton.setOnClickListener { binding.rotateButton.setOnClickListener {
requestedOrientation = requestedOrientation =

View File

@ -1,7 +1,8 @@
package sushi.hardcore.droidfs.filesystems package sushi.hardcore.droidfs.filesystems
import android.os.Parcel import android.os.Parcel
import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.util.ObjRef import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
@ -21,7 +22,8 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
givenHash: ByteArray?, givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>?, returnedHash: ObjRef<ByteArray?>?,
createBaseDir: Boolean, createBaseDir: Boolean,
cipher: String? cipher: String?,
errorCode: ObjRef<Int?>,
): Long ): Long
private external fun nativeChangeEncryptionKey( private external fun nativeChangeEncryptionKey(
baseDir: String, baseDir: String,
@ -47,7 +49,7 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
private external fun nativeIsClosed(fusePtr: Long): Boolean private external fun nativeIsClosed(fusePtr: Long): Boolean
fun getLocalStateDir(filesDir: String): String { fun getLocalStateDir(filesDir: String): String {
return PathUtils.pathJoin(filesDir, ConstValues.CRYFS_LOCAL_STATE_DIR) return PathUtils.pathJoin(filesDir, Constants.CRYFS_LOCAL_STATE_DIR)
} }
private fun init( private fun init(
@ -58,26 +60,34 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
returnedHash: ObjRef<ByteArray?>?, returnedHash: ObjRef<ByteArray?>?,
createBaseDir: Boolean, createBaseDir: Boolean,
cipher: String? cipher: String?
): CryfsVolume? { ): InitResult {
val fusePtr = nativeInit(baseDir, localStateDir, password, givenHash, returnedHash, createBaseDir, cipher) val errorCode = ObjRef<Int?>(null)
return if (fusePtr == 0L) { val fusePtr = nativeInit(baseDir, localStateDir, password, givenHash, returnedHash, createBaseDir, cipher, errorCode)
null val result = InitResult.Builder()
} else { if (fusePtr == 0L) {
CryfsVolume(fusePtr) 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?, volume: ObjRef<EncryptedVolume?>?): Boolean { fun create(baseDir: String, localStateDir: String, password: ByteArray, returnedHash: ObjRef<ByteArray?>?, cipher: String?): EncryptedVolume? {
return init(baseDir, localStateDir, password, null, returnedHash, true, cipher)?.also { return init(baseDir, localStateDir, password, null, returnedHash, true, cipher).volume
if (volume == null) {
it.close()
} else {
volume.value = it
}
} != null
} }
fun init(baseDir: String, localStateDir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ObjRef<ByteArray?>?): CryfsVolume? { fun init(baseDir: String, localStateDir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ObjRef<ByteArray?>?): InitResult {
return init(baseDir, localStateDir, password, givenHash, returnedHash, false, null) return init(baseDir, localStateDir, password, givenHash, returnedHash, false, null)
} }
@ -98,7 +108,11 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
writeLong(fusePtr) writeLong(fusePtr)
} }
override fun openFile(path: String): Long { override fun openFileReadMode(path: String): Long {
return nativeOpen(fusePtr, path, 0)
}
override fun openFileWriteMode(path: String): Long {
val fileHandle = nativeOpen(fusePtr, path, 0) val fileHandle = nativeOpen(fusePtr, path, 0)
return if (fileHandle == -1L) { return if (fileHandle == -1L) {
nativeCreate(fusePtr, path, 0) nativeCreate(fusePtr, path, 0)

View File

@ -4,17 +4,33 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.VolumeData import sushi.hardcore.droidfs.VolumeData
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.util.ObjRef import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
abstract class EncryptedVolume: Parcelable { abstract class EncryptedVolume: Parcelable {
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 { companion object {
const val GOCRYPTFS_VOLUME_TYPE: Byte = 0 const val GOCRYPTFS_VOLUME_TYPE: Byte = 0
const val CRYFS_VOLUME_TYPE: Byte = 1 const val CRYFS_VOLUME_TYPE: Byte = 1
@ -31,6 +47,11 @@ abstract class EncryptedVolume: Parcelable {
override fun newArray(size: Int) = arrayOfNulls<EncryptedVolume>(size) override fun newArray(size: Int) = arrayOfNulls<EncryptedVolume>(size)
} }
/**
* 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 { fun getVolumeType(path: String): Byte {
return if (File(path, GocryptfsVolume.CONFIG_FILE_NAME).isFile) { return if (File(path, GocryptfsVolume.CONFIG_FILE_NAME).isFile) {
GOCRYPTFS_VOLUME_TYPE GOCRYPTFS_VOLUME_TYPE
@ -47,7 +68,7 @@ abstract class EncryptedVolume: Parcelable {
password: ByteArray?, password: ByteArray?,
givenHash: ByteArray?, givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>? returnedHash: ObjRef<ByteArray?>?
): EncryptedVolume? { ): InitResult {
return when (volume.type) { return when (volume.type) {
GOCRYPTFS_VOLUME_TYPE -> { GOCRYPTFS_VOLUME_TYPE -> {
GocryptfsVolume.init( GocryptfsVolume.init(
@ -73,7 +94,8 @@ abstract class EncryptedVolume: Parcelable {
override fun describeContents() = 0 override fun describeContents() = 0
abstract fun openFile(path: String): Long 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 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 write(fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int
abstract fun closeFile(fileHandle: Long): Boolean abstract fun closeFile(fileHandle: Long): Boolean
@ -94,7 +116,7 @@ abstract class EncryptedVolume: Parcelable {
fun exportFile(fileHandle: Long, os: OutputStream): Boolean { fun exportFile(fileHandle: Long, os: OutputStream): Boolean {
var offset: Long = 0 var offset: Long = 0
val ioBuffer = ByteArray(ConstValues.IO_BUFF_SIZE) val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
var length: Int var length: Int
while (read(fileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it } > 0) { while (read(fileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it } > 0) {
os.write(ioBuffer, 0, length) os.write(ioBuffer, 0, length)
@ -106,7 +128,7 @@ abstract class EncryptedVolume: Parcelable {
fun exportFile(src_path: String, os: OutputStream): Boolean { fun exportFile(src_path: String, os: OutputStream): Boolean {
var success = false var success = false
val srcfileHandle = openFile(src_path) val srcfileHandle = openFileReadMode(src_path)
if (srcfileHandle != -1L) { if (srcfileHandle != -1L) {
success = exportFile(srcfileHandle, os) success = exportFile(srcfileHandle, os)
closeFile(srcfileHandle) closeFile(srcfileHandle)
@ -127,18 +149,17 @@ abstract class EncryptedVolume: Parcelable {
} }
fun importFile(inputStream: InputStream, dst_path: String): Boolean { fun importFile(inputStream: InputStream, dst_path: String): Boolean {
val dstfileHandle = openFile(dst_path) val dstfileHandle = openFileWriteMode(dst_path)
if (dstfileHandle != -1L) { if (dstfileHandle != -1L) {
var success = true var success = true
var offset: Long = 0 var offset: Long = 0
val ioBuffer = ByteArray(ConstValues.IO_BUFF_SIZE) val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
var length: Long var length: Long
while (inputStream.read(ioBuffer).also { length = it.toLong() } > 0) { while (inputStream.read(ioBuffer).also { length = it.toLong() } > 0) {
val written = write(dstfileHandle, offset, ioBuffer, 0, length).toLong() val written = write(dstfileHandle, offset, ioBuffer, 0, length).toLong()
if (written == length) { if (written == length) {
offset += written offset += written
} else { } else {
inputStream.close()
success = false success = false
break break
} }
@ -169,7 +190,7 @@ abstract class EncryptedVolume: Parcelable {
} }
try { try {
val fileBuff = ByteArray(fileSize.toInt()) val fileBuff = ByteArray(fileSize.toInt())
val fileHandle = openFile(fullPath) val fileHandle = openFileReadMode(fullPath)
if (fileHandle == -1L) { if (fileHandle == -1L) {
Pair(null, 3) Pair(null, 3)
} else { } else {
@ -201,25 +222,4 @@ abstract class EncryptedVolume: Parcelable {
} }
return result return result
} }
fun recursiveRemoveDirectory(path: String): String? {
readDir(path)?.let { elements ->
for (e in elements) {
val fullPath = PathUtils.pathJoin(path, e.name)
if (e.isDirectory) {
val result = recursiveRemoveDirectory(fullPath)
result?.let { return it }
} else {
if (!deleteFile(fullPath)) {
return fullPath
}
}
}
}
return if (!rmdir(path)) {
path
} else {
null
}
}
} }

View File

@ -2,6 +2,7 @@ package sushi.hardcore.droidfs.filesystems
import android.os.Parcel import android.os.Parcel
import android.util.Log import android.util.Log
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.explorers.ExplorerElement import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.util.ObjRef import sushi.hardcore.droidfs.util.ObjRef
import kotlin.math.min import kotlin.math.min
@ -36,7 +37,6 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
logN: Int, logN: Int,
creator: String, creator: String,
returnedHash: ByteArray?, returnedHash: ByteArray?,
openAfterCreation: Boolean,
): Int ): Int
private external fun nativeInit(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): Int private external fun nativeInit(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): Int
external fun changePassword( external fun changePassword(
@ -53,30 +53,46 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
plainTextNames: Boolean, plainTextNames: Boolean,
xchacha: Int, xchacha: Int,
returnedHash: ByteArray?, returnedHash: ByteArray?,
volume: ObjRef<EncryptedVolume?>? volume: ObjRef<EncryptedVolume?>
): Boolean { ): Boolean {
val openAfterCreation = volume != null return when (val result = nativeCreateVolume(
val result = nativeCreateVolume(root_cipher_dir, password, plainTextNames, xchacha, ScryptDefaultLogN, VOLUME_CREATOR, returnedHash, openAfterCreation) root_cipher_dir,
return if (!openAfterCreation) { password,
result == 1 plainTextNames,
} else if (result == -1) { xchacha,
ScryptDefaultLogN,
VOLUME_CREATOR,
returnedHash,
)) {
-1 -> {
Log.e("gocryptfs", "Failed to open volume after creation") Log.e("gocryptfs", "Failed to open volume after creation")
true true
} else if (result == -2) { }
false -2 -> false
} else { else -> {
volume!!.value = GocryptfsVolume(result) volume.value = GocryptfsVolume(result)
true true
} }
} }
}
fun init(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): GocryptfsVolume? { fun init(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): InitResult {
val sessionId = nativeInit(root_cipher_dir, password, givenHash, returnedHash) val sessionId = nativeInit(root_cipher_dir, password, givenHash, returnedHash)
return if (sessionId == -1) { val result = InitResult.Builder()
null if (sessionId < 0) {
} else { result.errorCode = sessionId
GocryptfsVolume(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 { init {
@ -86,7 +102,11 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
constructor(parcel: Parcel) : this(parcel.readInt()) constructor(parcel: Parcel) : this(parcel.readInt())
override fun openFile(path: String): Long { 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 return native_open_write_mode(sessionID, path, 384).toLong() // 0600
} }

View File

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

View File

@ -0,0 +1,23 @@
package sushi.hardcore.droidfs.util
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
object Compat {
inline fun <reified T: Parcelable> getParcelable(bundle: Bundle, name: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bundle.getParcelable(name, T::class.java)
} else {
@Suppress("Deprecation")
bundle.getParcelable(name)
}
}
val MEMFD_CREATE_MINIMUM_KERNEL_VERSION = Version("3.17")
fun isMemFileSupported(): Boolean {
val kernel = System.getProperty("os.version") ?: return false
return Version(kernel) >= MEMFD_CREATE_MINIMUM_KERNEL_VERSION
}
}

View File

@ -3,12 +3,16 @@ package sushi.hardcore.droidfs.util
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.Theme
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File import java.io.File
import java.text.DecimalFormat import java.text.DecimalFormat
@ -18,6 +22,7 @@ import kotlin.math.pow
object PathUtils { object PathUtils {
const val SEPARATOR = '/' const val SEPARATOR = '/'
const val PATH_RESOLVER_TAG = "PATH RESOLVER"
fun getParentPath(path: String): String { fun getParentPath(path: String): String {
val strippedPath = if (path.endsWith(SEPARATOR)) { val strippedPath = if (path.endsWith(SEPARATOR)) {
@ -95,11 +100,47 @@ object PathUtils {
return "Android/data/${context.packageName}/" return "Android/data/${context.packageName}/"
} }
private fun getExternalStoragePath(context: Context): List<String> { private fun getExternalStoragePath(context: Context, name: String): String? {
for (dir in ContextCompat.getExternalFilesDirs(context, null)) {
Log.d(PATH_RESOLVER_TAG, "External dir: $dir")
if (Environment.isExternalStorageRemovable(dir)) {
Log.d(PATH_RESOLVER_TAG, "isExternalStorageRemovable")
val path = dir.path.split("/Android")[0]
if (File(path).name == name) {
return path
}
}
}
Log.d(PATH_RESOLVER_TAG, "getExternalFilesDirs failed")
// Don't risk to be killed by SELinux on newer Android versions
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
try {
val process = ProcessBuilder("mount").redirectErrorStream(true).start().apply { waitFor() }
process.inputStream.readBytes().decodeToString().split("\n").forEach { line ->
if (line.startsWith("/dev/block/vold")) {
Log.d(PATH_RESOLVER_TAG, "mount: $line")
val fields = line.split(" ")
if (fields.size >= 3) {
val path = fields[2]
if (File(path).name == name) {
return path
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
Log.d(PATH_RESOLVER_TAG, "mount processing failed")
}
return null
}
private fun getExternalStoragesPaths(context: Context): List<String> {
val externalPaths: MutableList<String> = ArrayList() val externalPaths: MutableList<String> = ArrayList()
ContextCompat.getExternalFilesDirs(context, null).forEach { ContextCompat.getExternalFilesDirs(context, null).forEach {
if (Environment.isExternalStorageRemovable(it)) {
val rootPath = it.path.substring(0, it.path.indexOf(getPackageDataFolder(context)+"files")) val rootPath = it.path.substring(0, it.path.indexOf(getPackageDataFolder(context)+"files"))
if (!rootPath.endsWith("/0/")){ //not primary storage
externalPaths.add(rootPath) externalPaths.add(rootPath)
} }
} }
@ -107,7 +148,7 @@ object PathUtils {
} }
fun isPathOnExternalStorage(path: String, context: Context): Boolean { fun isPathOnExternalStorage(path: String, context: Context): Boolean {
getExternalStoragePath(context).forEach { getExternalStoragesPaths(context).forEach {
if (path.startsWith(it)){ if (path.startsWith(it)){
return true return true
} }
@ -116,18 +157,23 @@ object PathUtils {
} }
private const val PRIMARY_VOLUME_NAME = "primary" private const val PRIMARY_VOLUME_NAME = "primary"
fun getFullPathFromTreeUri(treeUri: Uri?, context: Context): String? { fun getFullPathFromTreeUri(treeUri: Uri, context: Context): String? {
if (treeUri == null) return null
if ("content".equals(treeUri.scheme, ignoreCase = true)) { if ("content".equals(treeUri.scheme, ignoreCase = true)) {
val vId = getVolumeIdFromTreeUri(treeUri) val vId = getVolumeIdFromTreeUri(treeUri)
var volumePath = getVolumePath(vId, context) ?: return null Log.d(PATH_RESOLVER_TAG, "Volume Id: $vId")
if (volumePath.endsWith(File.separator)) var volumePath = getVolumePath(vId ?: return null, context)
volumePath = volumePath.substring(0, volumePath.length - 1) Log.d(PATH_RESOLVER_TAG, "Volume Path: $volumePath")
var documentPath = getDocumentPathFromTreeUri(treeUri) if (volumePath == null) {
if (documentPath!!.endsWith(File.separator)) volumePath = if (vId == "primary") {
documentPath = documentPath.substring(0, documentPath.length - 1) Environment.getExternalStorageDirectory().path
} else {
getExternalStoragePath(context, vId) ?: "/storage/$vId"
}
}
val documentPath = getDocumentPathFromTreeUri(treeUri)!!
Log.d(PATH_RESOLVER_TAG, "Document Path: $documentPath")
return if (documentPath.isNotEmpty()) { return if (documentPath.isNotEmpty()) {
pathJoin(volumePath, documentPath) pathJoin(volumePath!!, documentPath)
} else volumePath } else volumePath
} else if ("file".equals(treeUri.scheme, ignoreCase = true)) { } else if ("file".equals(treeUri.scheme, ignoreCase = true)) {
return treeUri.path return treeUri.path
@ -135,7 +181,7 @@ object PathUtils {
return null return null
} }
private fun getVolumePath(volumeId: String?, context: Context): String? { private fun getVolumePath(volumeId: String, context: Context): String? {
return try { return try {
val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val mStorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume") val storageVolumeClazz = Class.forName("android.os.storage.StorageVolume")
@ -185,11 +231,11 @@ object PathUtils {
return rootDirectory.delete() return rootDirectory.delete()
} }
fun safePickDirectory(directoryPicker: ActivityResultLauncher<Uri?>, context: Context, themeValue: String) { fun safePickDirectory(directoryPicker: ActivityResultLauncher<Uri?>, context: Context, theme: Theme) {
try { try {
directoryPicker.launch(null) directoryPicker.launch(null)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
CustomAlertDialogBuilder(context, themeValue) CustomAlertDialogBuilder(context, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.open_tree_failed) .setMessage(R.string.open_tree_failed)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)

View File

@ -0,0 +1,42 @@
package sushi.hardcore.droidfs.util
import android.content.Context
import android.view.Menu
import android.widget.EditText
import androidx.core.content.ContextCompat
import sushi.hardcore.droidfs.R
import java.nio.CharBuffer
import java.nio.charset.StandardCharsets
import java.util.*
object UIUtils {
fun encodeEditTextContent(editText: EditText): ByteArray {
val charArray = CharArray(editText.text.length)
editText.text.getChars(0, editText.text.length, charArray, 0)
val byteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(charArray))
Arrays.fill(charArray, Char.MIN_VALUE)
val byteArray = ByteArray(byteBuffer.remaining())
byteBuffer.get(byteArray)
Wiper.wipe(byteBuffer)
return byteArray
}
class MenuIconColor(
private val context: Context,
private val menu: Menu,
private val color: Int
) {
fun applyTo(menuItemId: Int, drawableId: Int) {
menu.findItem(menuItemId)?.let {
it.icon = ContextCompat.getDrawable(context, drawableId)?.apply {
setTint(color)
}
}
}
}
fun getMenuIconNeutralTint(context: Context, menu: Menu) = MenuIconColor(
context, menu,
ContextCompat.getColor(context, R.color.neutralIconTint),
)
}

View File

@ -0,0 +1,29 @@
package sushi.hardcore.droidfs.util
import java.lang.Integer.max
class Version(inputVersion: String) : Comparable<Version> {
private val version: String
init {
val regex = "[0-9]+(\\.[0-9]+)*".toRegex()
val match = regex.find(inputVersion) ?: throw IllegalArgumentException("Invalid version format")
version = match.value
}
fun split() = version.split(".").toTypedArray()
override fun compareTo(other: Version) =
(split() to other.split()).let { (split, otherSplit) ->
val length = max(split.size, otherSplit.size)
for (i in 0 until length) {
val part = if (i < split.size) split[i].toInt() else 0
val otherPart = if (i < otherSplit.size) otherSplit[i].toInt() else 0
if (part < otherPart) return -1
if (part > otherPart) return 1
}
0
}
override fun toString() = version
}

View File

@ -1,18 +0,0 @@
package sushi.hardcore.droidfs.util
import android.widget.EditText
import java.nio.CharBuffer
import java.nio.charset.StandardCharsets
import java.util.*
object WidgetUtil {
fun encodeEditTextContent(editText: EditText): ByteArray {
val charArray = CharArray(editText.text.length)
editText.text.getChars(0, editText.text.length, charArray, 0)
val byteArray = StandardCharsets.UTF_8.encode(
CharBuffer.wrap(charArray)
).array()
Arrays.fill(charArray, Char.MIN_VALUE)
return byteArray
}
}

View File

@ -4,14 +4,25 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import sushi.hardcore.droidfs.ConstValues import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.nio.ByteBuffer
import java.util.* import java.util.*
import kotlin.math.ceil import kotlin.math.ceil
object Wiper { object Wiper {
fun wipe(byteBuffer: ByteBuffer) {
if (byteBuffer.hasArray()) {
Arrays.fill(byteBuffer.array(), Byte.MIN_VALUE)
} else {
for (i in 0 until byteBuffer.limit()) {
byteBuffer.put(i, Byte.MIN_VALUE)
}
}
}
private const val buff_size = 4096 private const val buff_size = 4096
fun wipe(context: Context, uri: Uri): String? { fun wipe(context: Context, uri: Uri): String? {
val cursor = context.contentResolver.query(uri, null, null, null, null) val cursor = context.contentResolver.query(uri, null, null, null, null)
@ -25,11 +36,11 @@ object Wiper {
val buff = ByteArray(buff_size) val buff = ByteArray(buff_size)
Arrays.fill(buff, 0.toByte()) Arrays.fill(buff, 0.toByte())
val writes = ceil(size.toDouble() / buff_size).toInt() val writes = ceil(size.toDouble() / buff_size).toInt()
for (i in 0 until ConstValues.WIPE_PASSES) { for (i in 0 until Constants.WIPE_PASSES) {
for (j in 0 until writes) { for (j in 0 until writes) {
os.write(buff) os.write(buff)
} }
if (i < ConstValues.WIPE_PASSES - 1) { if (i < Constants.WIPE_PASSES - 1) {
//reopening to flush and seek //reopening to flush and seek
os.close() os.close()
os = context.contentResolver.openOutputStream(uri)!! os = context.contentResolver.openOutputStream(uri)!!
@ -57,11 +68,11 @@ object Wiper {
val buff = ByteArray(buff_size) val buff = ByteArray(buff_size)
Arrays.fill(buff, 0.toByte()) Arrays.fill(buff, 0.toByte())
val writes = ceil(size.toDouble() / buff_size).toInt() val writes = ceil(size.toDouble() / buff_size).toInt()
for (i in 0 until ConstValues.WIPE_PASSES) { for (i in 0 until Constants.WIPE_PASSES) {
for (j in 0 until writes) { for (j in 0 until writes) {
os.write(buff) os.write(buff)
} }
if (i < ConstValues.WIPE_PASSES - 1) { if (i < Constants.WIPE_PASSES - 1) {
//reopening to flush and seek //reopening to flush and seek
os.close() os.close()
os = FileOutputStream(file) os = FileOutputStream(file)

View File

@ -0,0 +1,68 @@
package sushi.hardcore.droidfs.video_recording
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.Constants
import java.nio.ByteBuffer
class AsynchronousSeekableWriter(private val internalWriter: SeekableWriter): SeekableWriter {
internal enum class Operation { WRITE, SEEK, CLOSE }
internal class Task(
val operation: Operation,
val buffer: ByteArray? = null,
val offset: Long? = null,
)
private val channel = Channel<Task>(Channel.UNLIMITED)
private fun flush(buffer: ByteBuffer) {
internalWriter.write(buffer.array(), buffer.position())
buffer.position(0)
}
fun start() {
CoroutineScope(Dispatchers.IO).launch {
val buffer = ByteBuffer.allocate(Constants.IO_BUFF_SIZE)
while (true) {
val task = channel.receive()
when (task.operation) {
Operation.WRITE -> {
if (task.buffer!!.size > buffer.remaining()) {
flush(buffer)
}
buffer.put(task.buffer)
}
Operation.SEEK -> {
if (buffer.position() > 0) {
flush(buffer)
}
internalWriter.seek(task.offset!!)
}
Operation.CLOSE -> {
if (buffer.position() > 0) {
flush(buffer)
}
internalWriter.close()
break
}
}
}
}
}
override fun write(buffer: ByteArray, size: Int) {
channel.trySend(Task(Operation.WRITE, buffer)).exceptionOrNull()?.let { throw it }
}
override fun seek(offset: Long) {
channel.trySend(Task(Operation.SEEK, offset = offset)).exceptionOrNull()?.let { throw it }
}
override fun close() {
channel.trySend(Task(Operation.CLOSE)).exceptionOrNull()?.let { throw it }
}
}

View File

@ -0,0 +1,91 @@
package sushi.hardcore.droidfs.video_recording
import android.media.MediaCodec
import android.media.MediaFormat
import androidx.camera.video.MediaMuxer
import java.nio.ByteBuffer
class FFmpegMuxer(val writer: SeekableWriter): MediaMuxer {
external fun allocContext(): Long
external fun addVideoTrack(formatContext: Long, bitrate: Int, frameRate: Int, width: Int, height: Int, orientationHint: Int): Int
external fun addAudioTrack(formatContext: Long, bitrate: Int, sampleRate: Int, channelCount: Int): Int
external fun writeHeaders(formatContext: Long): Int
external fun writePacket(formatContext: Long, buffer: ByteArray, pts: Long, streamIndex: Int, isKeyFrame: Boolean)
external fun writeTrailer(formatContext: Long)
external fun release(formatContext: Long)
var formatContext: Long?
var orientation = 0
private var videoTrackIndex: Int? = null
private var audioTrackIndex: Int? = null
private var firstPts: Long? = null
init {
System.loadLibrary("mux")
formatContext = allocContext()
}
override fun writeSampleData(trackIndex: Int, buffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) {
val byteArray = ByteArray(bufferInfo.size)
buffer.get(byteArray)
if (firstPts == null) {
firstPts = bufferInfo.presentationTimeUs
}
writePacket(
formatContext!!, byteArray, bufferInfo.presentationTimeUs - firstPts!!, trackIndex,
bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0
)
}
override fun addTrack(mediaFormat: MediaFormat): Int {
val mime = mediaFormat.getString("mime")!!.split('/')
val bitrate = mediaFormat.getInteger("bitrate")
return if (mime[0] == "audio") {
addAudioTrack(
formatContext!!,
bitrate,
mediaFormat.getInteger("sample-rate"),
mediaFormat.getInteger("channel-count")
).also {
audioTrackIndex = it
}
} else {
addVideoTrack(
formatContext!!,
bitrate,
mediaFormat.getInteger("frame-rate"),
mediaFormat.getInteger("width"),
mediaFormat.getInteger("height"),
orientation
).also {
videoTrackIndex = it
}
}
}
override fun start() {
writeHeaders(formatContext!!)
}
override fun stop() {
writeTrailer(formatContext!!)
}
override fun setOrientationHint(degree: Int) {
orientation = degree
}
override fun release() {
writer.close()
release(formatContext!!)
firstPts = null
formatContext = null
}
fun writePacket(buff: ByteArray) {
writer.write(buff, buff.size)
}
fun seek(offset: Long) {
writer.seek(offset)
}
}

View File

@ -1,94 +0,0 @@
package sushi.hardcore.droidfs.video_recording
import android.media.MediaCodec
import android.media.MediaFormat
import java.nio.ByteBuffer
class MediaMuxer(val writer: SeekableWriter) {
external fun allocContext(): Long
external fun addVideoTrack(formatContext: Long, bitrate: Int, width: Int, height: Int, orientationHint: Int): Int
external fun addAudioTrack(formatContext: Long, bitrate: Int, sampleRate: Int, channelCount: Int): Int
external fun writeHeaders(formatContext: Long): Int
external fun writePacket(formatContext: Long, buffer: ByteArray, pts: Long, streamIndex: Int, isKeyFrame: Boolean)
external fun writeTrailer(formatContext: Long)
external fun release(formatContext: Long)
companion object {
const val VIDEO_TRACK_INDEX = 0
const val AUDIO_TRACK_INDEX = 1
}
var formatContext: Long?
var orientationHint = 0
var realVideoTrackIndex: Int? = null
var audioFrameSize: Int? = null
var firstPts: Long? = null
private var audioPts = 0L
init {
System.loadLibrary("mux")
formatContext = allocContext()
}
fun writeSampleData(trackIndex: Int, buffer: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) {
val byteArray = ByteArray(bufferInfo.size)
buffer.get(byteArray)
if (firstPts == null) {
firstPts = bufferInfo.presentationTimeUs
}
if (trackIndex == AUDIO_TRACK_INDEX) {
writePacket(formatContext!!, byteArray, audioPts, -1, false)
audioPts += audioFrameSize!!
} else {
writePacket(
formatContext!!, byteArray, bufferInfo.presentationTimeUs - firstPts!!, realVideoTrackIndex!!,
bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0
)
}
}
fun addTrack(format: MediaFormat): Int {
val mime = format.getString("mime")!!.split('/')
val bitrate = format.getInteger("bitrate")
return if (mime[0] == "audio") {
audioFrameSize = addAudioTrack(
formatContext!!,
bitrate,
format.getInteger("sample-rate"),
format.getInteger("channel-count")
)
AUDIO_TRACK_INDEX
} else {
realVideoTrackIndex = addVideoTrack(
formatContext!!,
bitrate,
format.getInteger("width"),
format.getInteger("height"),
orientationHint
)
VIDEO_TRACK_INDEX
}
}
fun start() {
writeHeaders(formatContext!!)
}
fun stop() {
writeTrailer(formatContext!!)
}
fun release() {
writer.close()
release(formatContext!!)
firstPts = null
audioPts = 0
formatContext = null
}
fun writePacket(buff: ByteArray) {
writer.write(buff)
}
fun seek(offset: Long) {
writer.seek(offset)
}
}

View File

@ -1,7 +1,7 @@
package sushi.hardcore.droidfs.video_recording package sushi.hardcore.droidfs.video_recording
interface SeekableWriter { interface SeekableWriter {
fun write(byteArray: ByteArray) fun write(buffer: ByteArray, size: Int)
fun seek(offset: Long) fun seek(offset: Long)
fun close() fun close()
} }

View File

@ -1,227 +0,0 @@
/*
* Copyright 2019 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 sushi.hardcore.droidfs.video_recording;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.camera.core.impl.Config;
import androidx.camera.core.impl.ImageFormatConstants;
import androidx.camera.core.impl.ImageOutputConfig;
import androidx.camera.core.impl.OptionsBundle;
import androidx.camera.core.impl.UseCaseConfig;
import androidx.camera.core.internal.ThreadConfig;
/**
* Config for a video capture use case.
*
* <p>In the earlier stage, the VideoCapture is deprioritized.
*/
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
public final class VideoCaptureConfig
implements UseCaseConfig<VideoCapture>,
ImageOutputConfig,
ThreadConfig {
// Option Declarations:
// *********************************************************************************************
public static final Option<Integer> OPTION_VIDEO_FRAME_RATE =
Option.create("camerax.core.videoCapture.recordingFrameRate", int.class);
public static final Option<Integer> OPTION_BIT_RATE =
Option.create("camerax.core.videoCapture.bitRate", int.class);
public static final Option<Integer> OPTION_INTRA_FRAME_INTERVAL =
Option.create("camerax.core.videoCapture.intraFrameInterval", int.class);
public static final Option<Integer> OPTION_AUDIO_BIT_RATE =
Option.create("camerax.core.videoCapture.audioBitRate", int.class);
public static final Option<Integer> OPTION_AUDIO_SAMPLE_RATE =
Option.create("camerax.core.videoCapture.audioSampleRate", int.class);
public static final Option<Integer> OPTION_AUDIO_CHANNEL_COUNT =
Option.create("camerax.core.videoCapture.audioChannelCount", int.class);
public static final Option<Integer> OPTION_AUDIO_MIN_BUFFER_SIZE =
Option.create("camerax.core.videoCapture.audioMinBufferSize", int.class);
// *********************************************************************************************
private final OptionsBundle mConfig;
public VideoCaptureConfig(@NonNull OptionsBundle config) {
mConfig = config;
}
/**
* Returns the recording frames per second.
*
* @param valueIfMissing The value to return if this configuration option has not been set.
* @return The stored value or <code>valueIfMissing</code> if the value does not exist in this
* configuration.
*/
public int getVideoFrameRate(int valueIfMissing) {
return retrieveOption(OPTION_VIDEO_FRAME_RATE, valueIfMissing);
}
/**
* Returns the recording frames per second.
*
* @return The stored value, if it exists in this configuration.
* @throws IllegalArgumentException if the option does not exist in this configuration.
*/
public int getVideoFrameRate() {
return retrieveOption(OPTION_VIDEO_FRAME_RATE);
}
/**
* Returns the encoding bit rate.
*
* @param valueIfMissing The value to return if this configuration option has not been set.
* @return The stored value or <code>valueIfMissing</code> if the value does not exist in this
* configuration.
*/
public int getBitRate(int valueIfMissing) {
return retrieveOption(OPTION_BIT_RATE, valueIfMissing);
}
/**
* Returns the encoding bit rate.
*
* @return The stored value, if it exists in this configuration.
* @throws IllegalArgumentException if the option does not exist in this configuration.
*/
public int getBitRate() {
return retrieveOption(OPTION_BIT_RATE);
}
/**
* Returns the number of seconds between each key frame.
*
* @param valueIfMissing The value to return if this configuration option has not been set.
* @return The stored value or <code>valueIfMissing</code> if the value does not exist in this
* configuration.
*/
public int getIFrameInterval(int valueIfMissing) {
return retrieveOption(OPTION_INTRA_FRAME_INTERVAL, valueIfMissing);
}
/**
* Returns the number of seconds between each key frame.
*
* @return The stored value, if it exists in this configuration.
* @throws IllegalArgumentException if the option does not exist in this configuration.
*/
public int getIFrameInterval() {
return retrieveOption(OPTION_INTRA_FRAME_INTERVAL);
}
/**
* Returns the audio encoding bit rate.
*
* @param valueIfMissing The value to return if this configuration option has not been set.
* @return The stored value or <code>valueIfMissing</code> if the value does not exist in this
* configuration.
*/
public int getAudioBitRate(int valueIfMissing) {
return retrieveOption(OPTION_AUDIO_BIT_RATE, valueIfMissing);
}
/**
* Returns the audio encoding bit rate.
*
* @return The stored value, if it exists in this configuration.
* @throws IllegalArgumentException if the option does not exist in this configuration.
*/
public int getAudioBitRate() {
return retrieveOption(OPTION_AUDIO_BIT_RATE);
}
/**
* Returns the audio sample rate.
*
* @param valueIfMissing The value to return if this configuration option has not been set.
* @return The stored value or <code>valueIfMissing</code> if the value does not exist in this
* configuration.
*/
public int getAudioSampleRate(int valueIfMissing) {
return retrieveOption(OPTION_AUDIO_SAMPLE_RATE, valueIfMissing);
}
/**
* Returns the audio sample rate.
*
* @return The stored value, if it exists in this configuration.
* @throws IllegalArgumentException if the option does not exist in this configuration.
*/
public int getAudioSampleRate() {
return retrieveOption(OPTION_AUDIO_SAMPLE_RATE);
}
/**
* Returns the audio channel count.
*
* @param valueIfMissing The value to return if this configuration option has not been set.
* @return The stored value or <code>valueIfMissing</code> if the value does not exist in this
* configuration.
*/
public int getAudioChannelCount(int valueIfMissing) {
return retrieveOption(OPTION_AUDIO_CHANNEL_COUNT, valueIfMissing);
}
/**
* Returns the audio channel count.
*
* @return The stored value, if it exists in this configuration.
* @throws IllegalArgumentException if the option does not exist in this configuration.
*/
public int getAudioChannelCount() {
return retrieveOption(OPTION_AUDIO_CHANNEL_COUNT);
}
/**
* Returns the audio minimum buffer size, in bytes.
*
* @param valueIfMissing The value to return if this configuration option has not been set.
* @return The stored value or <code>valueIfMissing</code> if the value does not exist in this
* configuration.
*/
public int getAudioMinBufferSize(int valueIfMissing) {
return retrieveOption(OPTION_AUDIO_MIN_BUFFER_SIZE, valueIfMissing);
}
/**
* Returns the audio minimum buffer size, in bytes.
*
* @return The stored value, if it exists in this configuration.
* @throws IllegalArgumentException if the option does not exist in this configuration.
*/
public int getAudioMinBufferSize() {
return retrieveOption(OPTION_AUDIO_MIN_BUFFER_SIZE);
}
/**
* Retrieves the format of the image that is fed as input.
*
* <p>This should always be PRIVATE for VideoCapture.
*/
@Override
public int getInputFormat() {
return ImageFormatConstants.INTERNAL_DEFINED_IMAGE_FORMAT_PRIVATE;
}
@NonNull
@Override
public Config getConfig() {
return mConfig;
}
}

View File

@ -185,14 +185,14 @@ internal class CircleClipTapView(context: Context, attrs: AttributeSet): View(co
updatePathShape() updatePathShape()
} }
override fun onDraw(canvas: Canvas?) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
// Background // Background
canvas?.clipPath(shapePath) canvas.clipPath(shapePath)
canvas?.drawPath(shapePath, backgroundPaint) canvas.drawPath(shapePath, backgroundPaint)
// Circle // Circle
canvas?.drawCircle(cX, cY, currentRadius, circlePaint) canvas.drawCircle(cX, cY, currentRadius, circlePaint)
} }
} }

View File

@ -7,24 +7,30 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.Theme
open class CustomAlertDialogBuilder(context: Context, theme: String) : AlertDialog.Builder( open class CustomAlertDialogBuilder(context: Context, theme: Theme) : AlertDialog.Builder(
context, when (theme) { context, if (theme.black) {
"black_green" -> R.style.BlackGreenDialog when (theme.color) {
"dark_red" -> R.style.DarkRedDialog "red" -> R.style.BlackRedDialog
"black_red" -> R.style.BlackRedDialog "blue" -> R.style.BlackBlueDialog
"dark_blue" -> R.style.DarkBlueDialog "yellow" -> R.style.BlackYellowDialog
"black_blue" -> R.style.BlackBlueDialog "orange" -> R.style.BlackOrangeDialog
"dark_yellow" -> R.style.DarkYellowDialog "purple" -> R.style.BlackPurpleDialog
"black_yellow" -> R.style.BlackYellowDialog "pink" -> R.style.BlackPinkDialog
"dark_orange" -> R.style.DarkOrangeDialog else -> R.style.BlackGreenDialog
"black_orange" -> R.style.BlackOrangeDialog }
"dark_purple" -> R.style.DarkPurpleDialog } else {
"black_purple" -> R.style.BlackPurpleDialog when (theme.color) {
"dark_pink" -> R.style.DarkPinkDialog "red" -> R.style.DarkRedDialog
"black_pink" -> R.style.BlackPinkDialog "blue" -> R.style.DarkBlueDialog
"yellow" -> R.style.DarkYellowDialog
"orange" -> R.style.DarkOrangeDialog
"purple" -> R.style.DarkPurpleDialog
"pink" -> R.style.DarkPinkDialog
else -> R.style.DarkGreenDialog else -> R.style.DarkGreenDialog
} }
}
) { ) {
private var keepFullScreen = false private var keepFullScreen = false

View File

@ -17,14 +17,14 @@ import android.widget.LinearLayout
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import com.google.android.exoplayer2.ui.StyledPlayerView import androidx.media3.ui.PlayerView
import sushi.hardcore.droidfs.R import sushi.hardcore.droidfs.R
class DoubleTapPlayerView @JvmOverloads constructor( class DoubleTapPlayerView @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyleAttr: Int = 0 defStyleAttr: Int = 0
) : StyledPlayerView(context, attrs, defStyleAttr) { ) : PlayerView(context, attrs, defStyleAttr) {
companion object { companion object {
const val SEEK_SECONDS = 10 const val SEEK_SECONDS = 10

View File

@ -10,7 +10,7 @@ class EditTextDialog(
activity: BaseActivity, activity: BaseActivity,
private val titleId: Int, private val titleId: Int,
private val callback: (String) -> Unit, private val callback: (String) -> Unit,
): CustomAlertDialogBuilder(activity, activity.themeValue) { ): CustomAlertDialogBuilder(activity, activity.theme) {
val binding = DialogEditTextBinding.inflate(activity.layoutInflater) val binding = DialogEditTextBinding.inflate(activity.layoutInflater)
fun setSelectedText(text: CharSequence) { fun setSelectedText(text: CharSequence) {

View File

@ -1,8 +1,6 @@
#include <jni.h> #include <jni.h>
#include <stdio.h>
#include <malloc.h> #include <malloc.h>
#include <string.h> #include <string.h>
#include <sys/stat.h>
#include "libgocryptfs.h" #include "libgocryptfs.h"
const int KeyLen = 32; const int KeyLen = 32;
@ -15,38 +13,34 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_00024Companion_nativeCre
jint xchacha, jint xchacha,
jint logN, jint logN,
jstring jcreator, jstring jcreator,
jbyteArray jreturned_hash, jbyteArray jreturned_hash) {
jboolean open_after_creation) {
const char* root_cipher_dir = (*env)->GetStringUTFChars(env, jroot_cipher_dir, NULL); const char* root_cipher_dir = (*env)->GetStringUTFChars(env, jroot_cipher_dir, NULL);
const char* creator = (*env)->GetStringUTFChars(env, jcreator, NULL); const char* creator = (*env)->GetStringUTFChars(env, jcreator, NULL);
GoString gofilename = {root_cipher_dir, strlen(root_cipher_dir)}, gocreator = {creator, strlen(creator)}; GoString gofilename = {root_cipher_dir, strlen(root_cipher_dir)}, gocreator = {creator, strlen(creator)};
const size_t password_len = (*env)->GetArrayLength(env, jpassword); const size_t password_len = (const size_t) (*env)->GetArrayLength(env, jpassword);
jbyte* password = (*env)->GetByteArrayElements(env, jpassword, NULL); jbyte* password = (*env)->GetByteArrayElements(env, jpassword, NULL);
GoSlice go_password = {password, password_len, password_len}; GoSlice go_password = {password, password_len, password_len};
size_t returned_hash_len; size_t returned_hash_len;
GoSlice go_returned_hash; GoSlice go_returned_hash;
if (!(*env)->IsSameObject(env, jreturned_hash, NULL)) { if (!(*env)->IsSameObject(env, jreturned_hash, NULL)) {
returned_hash_len = (*env)->GetArrayLength(env, jreturned_hash); returned_hash_len = (size_t) (*env)->GetArrayLength(env, jreturned_hash);
go_returned_hash.data = (*env)->GetByteArrayElements(env, jreturned_hash, NULL); go_returned_hash.data = (*env)->GetByteArrayElements(env, jreturned_hash, NULL);
} else if (open_after_creation) { } else {
returned_hash_len = KeyLen; returned_hash_len = KeyLen;
go_returned_hash.data = malloc(KeyLen); go_returned_hash.data = malloc(KeyLen);
} else {
returned_hash_len = 0;
go_returned_hash.data = NULL;
} }
go_returned_hash.len = returned_hash_len; go_returned_hash.len = returned_hash_len;
go_returned_hash.cap = returned_hash_len; go_returned_hash.cap = returned_hash_len;
GoUint8 result = gcf_create_volume(gofilename, go_password, plainTextNames, xchacha, logN, gocreator, go_returned_hash); GoUint8 result = gcf_create_volume(gofilename, go_password, plainTextNames, (GoInt8) xchacha, logN, gocreator, go_returned_hash);
(*env)->ReleaseByteArrayElements(env, jpassword, password, 0); (*env)->ReleaseByteArrayElements(env, jpassword, password, 0);
(*env)->ReleaseStringUTFChars(env, jcreator, creator); (*env)->ReleaseStringUTFChars(env, jcreator, creator);
GoInt sessionID = -2; GoInt sessionID = -2;
if (result && open_after_creation) { if (result) {
GoSlice null_slice = {NULL, 0, 0}; GoSlice null_slice = {NULL, 0, 0};
sessionID = gcf_init(gofilename, null_slice, go_returned_hash, null_slice); sessionID = gcf_init(gofilename, null_slice, go_returned_hash, null_slice);
} }
@ -55,14 +49,14 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_00024Companion_nativeCre
if (!(*env)->IsSameObject(env, jreturned_hash, NULL)) { if (!(*env)->IsSameObject(env, jreturned_hash, NULL)) {
(*env)->ReleaseByteArrayElements(env, jreturned_hash, go_returned_hash.data, 0); (*env)->ReleaseByteArrayElements(env, jreturned_hash, go_returned_hash.data, 0);
} else if (open_after_creation) { } else {
for (unsigned int i=0; i<returned_hash_len; ++i) { for (unsigned int i=0; i<returned_hash_len; ++i) {
((unsigned char*) go_returned_hash.data)[i] = 0; ((unsigned char*) go_returned_hash.data)[i] = 0;
} }
free(go_returned_hash.data); free(go_returned_hash.data);
} }
return sessionID*open_after_creation+result*!open_after_creation; return sessionID;
} }
JNIEXPORT jint JNICALL JNIEXPORT jint JNICALL
@ -81,13 +75,13 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_00024Companion_nativeIni
jbyte* given_hash; jbyte* given_hash;
GoSlice go_given_hash = {NULL, 0, 0}; GoSlice go_given_hash = {NULL, 0, 0};
if ((*env)->IsSameObject(env, jgiven_hash, NULL)){ if ((*env)->IsSameObject(env, jgiven_hash, NULL)){
password_len = (*env)->GetArrayLength(env, jpassword); password_len = (size_t) (*env)->GetArrayLength(env, jpassword);
password = (*env)->GetByteArrayElements(env, jpassword, NULL); password = (*env)->GetByteArrayElements(env, jpassword, NULL);
go_password.data = password; go_password.data = password;
go_password.len = password_len; go_password.len = password_len;
go_password.cap = password_len; go_password.cap = password_len;
} else { } else {
given_hash_len = (*env)->GetArrayLength(env, jgiven_hash); given_hash_len = (size_t) (*env)->GetArrayLength(env, jgiven_hash);
given_hash = (*env)->GetByteArrayElements(env, jgiven_hash, NULL); given_hash = (*env)->GetByteArrayElements(env, jgiven_hash, NULL);
go_given_hash.data = given_hash; go_given_hash.data = given_hash;
go_given_hash.len = given_hash_len; go_given_hash.len = given_hash_len;
@ -98,7 +92,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_00024Companion_nativeIni
jbyte* returned_hash; jbyte* returned_hash;
GoSlice go_returned_hash = {NULL, 0, 0}; GoSlice go_returned_hash = {NULL, 0, 0};
if (!(*env)->IsSameObject(env, jreturned_hash, NULL)){ if (!(*env)->IsSameObject(env, jreturned_hash, NULL)){
returned_hash_len = (*env)->GetArrayLength(env, jreturned_hash); returned_hash_len = (size_t) (*env)->GetArrayLength(env, jreturned_hash);
returned_hash = (*env)->GetByteArrayElements(env, jreturned_hash, NULL); returned_hash = (*env)->GetByteArrayElements(env, jreturned_hash, NULL);
go_returned_hash.data = returned_hash; go_returned_hash.data = returned_hash;
go_returned_hash.len = returned_hash_len; go_returned_hash.len = returned_hash_len;
@ -145,20 +139,20 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_00024Companion_changePas
jbyte* given_hash; jbyte* given_hash;
GoSlice go_given_hash = {NULL, 0, 0}; GoSlice go_given_hash = {NULL, 0, 0};
if ((*env)->IsSameObject(env, jgiven_hash, NULL)){ if ((*env)->IsSameObject(env, jgiven_hash, NULL)){
old_password_len = (*env)->GetArrayLength(env, jold_password); old_password_len = (size_t) (*env)->GetArrayLength(env, jold_password);
old_password = (*env)->GetByteArrayElements(env, jold_password, NULL); old_password = (*env)->GetByteArrayElements(env, jold_password, NULL);
go_old_password.data = old_password; go_old_password.data = old_password;
go_old_password.len = old_password_len; go_old_password.len = old_password_len;
go_old_password.cap = old_password_len; go_old_password.cap = old_password_len;
} else { } else {
given_hash_len = (*env)->GetArrayLength(env, jgiven_hash); given_hash_len = (size_t) (*env)->GetArrayLength(env, jgiven_hash);
given_hash = (*env)->GetByteArrayElements(env, jgiven_hash, NULL); given_hash = (*env)->GetByteArrayElements(env, jgiven_hash, NULL);
go_given_hash.data = given_hash; go_given_hash.data = given_hash;
go_given_hash.len = given_hash_len; go_given_hash.len = given_hash_len;
go_given_hash.cap = given_hash_len; go_given_hash.cap = given_hash_len;
} }
size_t new_password_len = (*env)->GetArrayLength(env, jnew_password); size_t new_password_len = (size_t) (*env)->GetArrayLength(env, jnew_password);
jbyte* new_password = (*env)->GetByteArrayElements(env, jnew_password, NULL); jbyte* new_password = (*env)->GetByteArrayElements(env, jnew_password, NULL);
GoSlice go_new_password = {new_password, new_password_len, new_password_len}; GoSlice go_new_password = {new_password, new_password_len, new_password_len};
@ -166,7 +160,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_00024Companion_changePas
jbyte* returned_hash; jbyte* returned_hash;
GoSlice go_returned_hash = {NULL, 0, 0}; GoSlice go_returned_hash = {NULL, 0, 0};
if (!(*env)->IsSameObject(env, jreturned_hash, NULL)) { if (!(*env)->IsSameObject(env, jreturned_hash, NULL)) {
returned_hash_len = (*env)->GetArrayLength(env, jreturned_hash); returned_hash_len = (size_t) (*env)->GetArrayLength(env, jreturned_hash);
returned_hash = (*env)->GetByteArrayElements(env, jreturned_hash, NULL); returned_hash = (*env)->GetByteArrayElements(env, jreturned_hash, NULL);
go_returned_hash.data = returned_hash; go_returned_hash.data = returned_hash;
go_returned_hash.len = returned_hash_len; go_returned_hash.len = returned_hash_len;
@ -303,7 +297,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1open_1write_1mod
const char* file_path = (*env)->GetStringUTFChars(env, jfile_path, NULL); const char* file_path = (*env)->GetStringUTFChars(env, jfile_path, NULL);
GoString go_file_path = {file_path, strlen(file_path)}; GoString go_file_path = {file_path, strlen(file_path)};
GoInt handleID = gcf_open_write_mode(sessionID, go_file_path, mode); GoInt handleID = gcf_open_write_mode(sessionID, go_file_path, (GoUint32) mode);
(*env)->ReleaseStringUTFChars(env, jfile_path, file_path); (*env)->ReleaseStringUTFChars(env, jfile_path, file_path);
@ -317,7 +311,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1write_1file(JNIE
jbyte* buff = (*env)->GetByteArrayElements(env, jbuff, NULL); jbyte* buff = (*env)->GetByteArrayElements(env, jbuff, NULL);
GoSlice go_buff = {buff+src_offset, length, length}; GoSlice go_buff = {buff+src_offset, length, length};
int written = gcf_write_file(sessionID, handleID, file_offset, go_buff); int written = gcf_write_file(sessionID, handleID, (GoUint64) file_offset, go_buff);
(*env)->ReleaseByteArrayElements(env, jbuff, buff, 0); (*env)->ReleaseByteArrayElements(env, jbuff, buff, 0);
@ -331,7 +325,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1read_1file(JNIEn
jbyte* buff = (*env)->GetByteArrayElements(env, jbuff, NULL); jbyte* buff = (*env)->GetByteArrayElements(env, jbuff, NULL);
GoSlice go_buff = {buff+dst_offset, length, length}; GoSlice go_buff = {buff+dst_offset, length, length};
int read = gcf_read_file(sessionID, handleID, file_offset, go_buff); int read = gcf_read_file(sessionID, handleID, (GoUint64) file_offset, go_buff);
(*env)->ReleaseByteArrayElements(env, jbuff, buff, 0); (*env)->ReleaseByteArrayElements(env, jbuff, buff, 0);
return read; return read;
@ -345,7 +339,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1truncate(JNIEnv
const char* path = (*env)->GetStringUTFChars(env, jpath, NULL); const char* path = (*env)->GetStringUTFChars(env, jpath, NULL);
GoString go_path = {path, strlen(path)}; GoString go_path = {path, strlen(path)};
GoUint8 result = gcf_truncate(sessionID, go_path, offset); GoUint8 result = gcf_truncate(sessionID, go_path, (GoUint64) offset);
(*env)->ReleaseStringUTFChars(env, jpath, path); (*env)->ReleaseStringUTFChars(env, jpath, path);
return result; return result;
@ -377,7 +371,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1mkdir(JNIEnv *en
const char* dir_path = (*env)->GetStringUTFChars(env, jdir_path, NULL); const char* dir_path = (*env)->GetStringUTFChars(env, jdir_path, NULL);
GoString go_dir_path = {dir_path, strlen(dir_path)}; GoString go_dir_path = {dir_path, strlen(dir_path)};
GoUint8 result = gcf_mkdir(sessionID, go_dir_path, mode); GoUint8 result = gcf_mkdir(sessionID, go_dir_path, (GoUint32) mode);
(*env)->ReleaseStringUTFChars(env, jdir_path, dir_path); (*env)->ReleaseStringUTFChars(env, jdir_path, dir_path);

View File

@ -7,9 +7,9 @@ Java_sushi_hardcore_droidfs_filesystems_CryfsVolume_00024Companion_nativeInit(JN
jstring base_dir, jstring jlocalStateDir, jstring base_dir, jstring jlocalStateDir,
jbyteArray password, jbyteArray givenHash, jbyteArray password, jbyteArray givenHash,
jobject returnedHash, jobject returnedHash,
jboolean createBaseDir, jboolean createBaseDir, jstring cipher,
jstring cipher) { jobject jerrorCode) {
return cryfs_init(env, base_dir, jlocalStateDir, password, givenHash, returnedHash, createBaseDir, cipher); return cryfs_init(env, base_dir, jlocalStateDir, password, givenHash, returnedHash, createBaseDir, cipher, jerrorCode);
} }
JNIEXPORT jboolean JNICALL JNIEXPORT jboolean JNICALL

View File

@ -3,9 +3,36 @@
#include <libavutil/channel_layout.h> #include <libavutil/channel_layout.h>
#include <libavutil/display.h> #include <libavutil/display.h>
#include <jni.h> #include <jni.h>
#include <android/log.h>
const char* LOG_TAG = "LIBMUX";
const size_t BUFF_SIZE = 4096; const size_t BUFF_SIZE = 4096;
int to_android_log_level(int level) {
switch (level) {
case AV_LOG_PANIC:
case AV_LOG_FATAL: return ANDROID_LOG_FATAL;
case AV_LOG_ERROR: return ANDROID_LOG_ERROR;
case AV_LOG_WARNING: return ANDROID_LOG_WARN;
case AV_LOG_INFO: return ANDROID_LOG_INFO;
default: return ANDROID_LOG_UNKNOWN;
}
}
void log_callback(void *ptr, int level, const char *fmt, va_list vl)
{
char line[1024];
static int print_prefix = 1;
av_log_format_line(ptr, level, fmt, vl, line, sizeof(line), &print_prefix);
__android_log_print(to_android_log_level(level), LOG_TAG, "%s", line);
}
void log_err(int result, const char* name) {
if (result < 0) {
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "%s: %d", name, result);
}
}
struct Muxer { struct Muxer {
JavaVM* jvm; JavaVM* jvm;
jobject thiz; jobject thiz;
@ -31,7 +58,9 @@ int64_t seek(void* opaque, int64_t offset, int whence) {
return offset; return offset;
} }
jlong Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_allocContext(JNIEnv *env, jobject thiz) { jlong Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_allocContext(JNIEnv *env, jobject thiz) {
av_log_set_callback(log_callback);
av_log_set_level(AV_LOG_INFO);
const AVOutputFormat *format = av_guess_format("mp4", NULL, NULL); const AVOutputFormat *format = av_guess_format("mp4", NULL, NULL);
struct Muxer* muxer = malloc(sizeof(struct Muxer)); struct Muxer* muxer = malloc(sizeof(struct Muxer));
(*env)->GetJavaVM(env, &muxer->jvm); (*env)->GetJavaVM(env, &muxer->jvm);
@ -47,30 +76,28 @@ jlong Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_allocContext(JNIEn
} }
JNIEXPORT jint JNICALL JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_addAudioTrack(JNIEnv *env, jobject thiz, jlong format_context, jint bitrate, jint sample_rate, Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_addAudioTrack(JNIEnv *env, jobject thiz, jlong format_context, jint bitrate, jint sample_rate,
jint channel_count) { jint channel_count) {
const AVCodec* encoder = avcodec_find_encoder(AV_CODEC_ID_AAC); const AVCodec* encoder = avcodec_find_encoder(AV_CODEC_ID_AAC);
AVStream* stream = avformat_new_stream((AVFormatContext *) format_context, NULL);
AVCodecContext* codec_context = avcodec_alloc_context3(encoder); AVCodecContext* codec_context = avcodec_alloc_context3(encoder);
av_channel_layout_default(&codec_context->ch_layout, channel_count); av_channel_layout_default(&codec_context->ch_layout, channel_count);
codec_context->sample_rate = sample_rate; codec_context->sample_rate = sample_rate;
codec_context->sample_fmt = encoder->sample_fmts[0]; codec_context->sample_fmt = encoder->sample_fmts[0];
codec_context->bit_rate = bitrate; codec_context->bit_rate = bitrate;
codec_context->strict_std_compliance = FF_COMPLIANCE_EXPERIMENTAL; codec_context->time_base = (AVRational) {1, sample_rate};
stream->time_base.den = sample_rate;
stream->time_base.num = 1;
codec_context->flags = AV_CODEC_FLAG_GLOBAL_HEADER;
avcodec_open2(codec_context, encoder, NULL); avcodec_open2(codec_context, encoder, NULL);
AVStream* stream = avformat_new_stream((AVFormatContext *) format_context, NULL);
avcodec_parameters_from_context(stream->codecpar, codec_context); avcodec_parameters_from_context(stream->codecpar, codec_context);
int frame_size = codec_context->frame_size;
avcodec_free_context(&codec_context); avcodec_free_context(&codec_context);
return frame_size; return stream->index;
} }
JNIEXPORT jint JNICALL JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_addVideoTrack(JNIEnv *env, jobject thiz, Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_addVideoTrack(JNIEnv *env, jobject thiz,
jlong format_context, jlong format_context,
jint bitrate, jint width, jint bitrate,
jint frame_rate,
jint width,
jint height, jint height,
jint orientation_hint) { jint orientation_hint) {
AVStream* stream = avformat_new_stream((AVFormatContext *) format_context, NULL); AVStream* stream = avformat_new_stream((AVFormatContext *) format_context, NULL);
@ -79,51 +106,55 @@ Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_addVideoTrack(JNIEnv *en
stream->codecpar->bit_rate = bitrate; stream->codecpar->bit_rate = bitrate;
stream->codecpar->width = width; stream->codecpar->width = width;
stream->codecpar->height = height; stream->codecpar->height = height;
stream->codecpar->format = AV_PIX_FMT_YUVJ420P;
stream->time_base = (AVRational) {1, frame_rate};
uint8_t* matrix = av_stream_new_side_data(stream, AV_PKT_DATA_DISPLAYMATRIX, sizeof(int32_t) * 9); uint8_t* matrix = av_stream_new_side_data(stream, AV_PKT_DATA_DISPLAYMATRIX, sizeof(int32_t) * 9);
av_display_rotation_set((int32_t *) matrix, orientation_hint); av_display_rotation_set((int32_t *) matrix, orientation_hint);
return stream->index; return stream->index;
} }
JNIEXPORT jint JNICALL JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_writeHeaders(JNIEnv *env, jobject thiz, jlong format_context) { Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_writeHeaders(JNIEnv *env, jobject thiz, jlong format_context) {
return avformat_write_header((AVFormatContext *) format_context, NULL); av_dump_format((AVFormatContext *) format_context, 0, NULL, 1);
int result = avformat_write_header((AVFormatContext *) format_context, NULL);
log_err(result, "avformat_write_header");
return result;
} }
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_writePacket(JNIEnv *env, jobject thiz, jlong format_context, Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_writePacket(JNIEnv *env, jobject thiz, jlong format_context,
jbyteArray buffer, jlong pts, jint stream_index, jbyteArray buffer, jlong pts, jint stream_index,
jboolean is_key_frame) { jboolean is_key_frame) {
AVPacket* packet = av_packet_alloc(); AVPacket* packet = av_packet_alloc();
int size = (*env)->GetArrayLength(env, buffer); int size = (*env)->GetArrayLength(env, buffer);
av_new_packet(packet, size); av_new_packet(packet, size);
packet->pts = pts;
if (stream_index >= 0) { //video
packet->stream_index = stream_index; packet->stream_index = stream_index;
AVRational r; AVRational r;
r.num = 1; r.num = 1;
r.den = 1000000; r.den = 1000000;
av_packet_rescale_ts(packet, r, ((AVFormatContext *)format_context)->streams[stream_index]->time_base); packet->pts = av_rescale_q(pts, r, ((AVFormatContext*)format_context)->streams[stream_index]->time_base);
} packet->dts = packet->pts;
uint8_t* buff = malloc(size); uint8_t* buff = malloc(size);
(*env)->GetByteArrayRegion(env, buffer, 0, size, (signed char*)buff); (*env)->GetByteArrayRegion(env, buffer, 0, size, (signed char*)buff);
packet->data = buff; packet->data = buff;
if (is_key_frame) { if (is_key_frame) {
packet->flags = AV_PKT_FLAG_KEY; packet->flags = AV_PKT_FLAG_KEY;
} }
av_write_frame((AVFormatContext *)format_context, packet); log_err(av_write_frame((AVFormatContext *)format_context, packet), "av_write_frame");
free(buff); free(buff);
av_packet_free(&packet); av_packet_free(&packet);
} }
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_writeTrailer(JNIEnv *env, jobject thiz, jlong format_context) { Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_writeTrailer(JNIEnv *env, jobject thiz, jlong format_context) {
av_write_trailer((AVFormatContext *) format_context); log_err(av_write_trailer((AVFormatContext *) format_context), "av_write_trailer");
} }
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_sushi_hardcore_droidfs_video_1recording_MediaMuxer_release(JNIEnv *env, jobject thiz, jlong format_context) { Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_release(JNIEnv *env, jobject thiz, jlong format_context) {
AVFormatContext* fc = (AVFormatContext *) format_context; AVFormatContext* fc = (AVFormatContext *) format_context;
av_free(fc->pb->buffer); av_free(fc->pb->buffer);
(*env)->DeleteGlobalRef(env, ((struct Muxer*)fc->pb->opaque)->thiz);
free(fc->pb->opaque); free(fc->pb->opaque);
avio_context_free(&fc->pb); avio_context_free(&fc->pb);
avformat_free_context(fc); avformat_free_context(fc);

View File

@ -0,0 +1,30 @@
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <jni.h>
#include <android/log.h>
const char* LOG_TAG = "MemFile";
void log_err(const char* function) {
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "%s(): %s", function, strerror(errno));
}
JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_MemFile_00024Companion_createMemFile(JNIEnv *env, jobject thiz, jstring jname,
jlong size) {
const char* name = (*env)->GetStringUTFChars(env, jname, NULL);
int fd = syscall(SYS_memfd_create, name, MFD_CLOEXEC);
if (fd < 0) {
log_err("memfd_create");
return fd;
}
if (ftruncate64(fd, size) == -1) {
log_err("ftruncate64");
close(fd);
return -1;
}
return fd;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

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