Compare commits
222 Commits
Author | SHA1 | Date | |
---|---|---|---|
4aa211bca4 | |||
0a1406769b | |||
a62f32e364 | |||
f865c864a2 | |||
e804059b23 | |||
![]() |
bb821d5f41 | ||
6c0e20c68e | |||
e9b67bd9c4 | |||
c0dcaed8d2 | |||
85e24921fa | |||
15f288be11 | |||
bb49501403 | |||
33d565bf22 | |||
52a29b034c | |||
d44601f69f | |||
4b002c7b24 | |||
7c72c4e829 | |||
bd60e62635 | |||
![]() |
d1e042c347 | ||
![]() |
0805ebda35 | ||
![]() |
36e6ad99b3 | ||
![]() |
967d4551c5 | ||
![]() |
b747d2822a | ||
![]() |
e5652666d8 | ||
![]() |
cda0e90b96 | ||
6f43bc7417 | |||
c26ab661c2 | |||
1c15f9fac8 | |||
b4635dc2e0 | |||
f4e47c1827 | |||
5474d6eea5 | |||
719faa31ee | |||
a41cde1c53 | |||
b503f134d5 | |||
3ba774fda3 | |||
b2154d319e | |||
571a79cc1d | |||
891a581329 | |||
f1a9c1383c | |||
ac71ad887d | |||
e1fe329f49 | |||
dfff597ae5 | |||
bd429648b3 | |||
71ff37b170 | |||
4afe56b13c | |||
217334a959 | |||
![]() |
2666313676 | ||
![]() |
04e154a6d9 | ||
![]() |
d3760e2194 | ||
d6c777875e | |||
8a18270b33 | |||
79db84f81d | |||
6d04349b2e | |||
de0194a722 | |||
3127a15d9e | |||
a08da2eacb | |||
1727170cb6 | |||
8776d2ee28 | |||
5642e28b44 | |||
1b7e5904be | |||
cb3fc3c70e | |||
393c458495 | |||
cdf98a7190 | |||
2ae41f0f79 | |||
![]() |
f85f9d1c44 | ||
9fc981fee8 | |||
ad19b9e645 | |||
87ffbc3cc1 | |||
b3a25e03e7 | |||
4c412be7dc | |||
f4f3239bb1 | |||
481558bd56 | |||
8d0a797469 | |||
a4ce35c95d | |||
e51bd2ceba | |||
2bbf003df5 | |||
e83cfc9794 | |||
![]() |
9d1bfd606f | ||
49ec2eaf49 | |||
8c9c6a20b9 | |||
f6d1fc8b67 | |||
![]() |
de3a1a9538 | ||
0a089c46ca | |||
05f4610407 | |||
![]() |
451f36c770 | ||
df3f84f526 | |||
24215a8b31 | |||
eb4e13af46 | |||
aea17aa7cb | |||
e918a2f94c | |||
e6761d1798 | |||
c434d79c06 | |||
821c853a22 | |||
22b1522192 | |||
5090a7aa03 | |||
1a1d3ea570 | |||
2d165c4a20 | |||
883874a5ab | |||
6e500c23e5 | |||
a726f7a7d0 | |||
1e75e9a32f | |||
5e9656970a | |||
5dbef99949 | |||
d2f11c85d1 | |||
25dbcca854 | |||
![]() |
545275dabc | ||
![]() |
077f5cc856 | ||
![]() |
2e07ee5333 | ||
34aad2596d | |||
cdc269f2f7 | |||
991e435e5e | |||
7c2f87109a | |||
4df1086734 | |||
7cdfc32c31 | |||
8f5afca823 | |||
11cc15536f | |||
2d19895e6d | |||
e2539a53b9 | |||
17c32f2144 | |||
a5b6de1138 | |||
d1ca164934 | |||
1a21a43f05 | |||
4d164944c1 | |||
8709abd7d7 | |||
e01932acda | |||
cf4927a90b | |||
![]() |
cb5679515c | ||
![]() |
a728bd8d24 | ||
83dd759f36 | |||
5144947a4a | |||
![]() |
6b52eed9d0 | ||
![]() |
2a257d91d0 | ||
f837556af5 | |||
b7ab267d16 | |||
5ea0b8ad41 | |||
ec348383c6 | |||
c8d266150c | |||
![]() |
4bbc9360b4 | ||
8aa2be2b05 | |||
e2248220c4 | |||
7959b20b3f | |||
![]() |
8cebe499f0 | ||
![]() |
a22b9d8fa8 | ||
cba1418417 | |||
b6b8bba666 | |||
e00abdf5bb | |||
72cce1d7e1 | |||
55b0ac0daa | |||
53f28e9475 | |||
f1d4b07726 | |||
339309b00d | |||
e6a1285e0a | |||
ab48f9219b | |||
c521c7f998 | |||
1d13dfbde3 | |||
36ab66fb43 | |||
1caabc2554 | |||
f541504e07 | |||
4de5b41102 | |||
4f9aa55dfe | |||
91de54018d | |||
2697eaf11b | |||
9e69805ade | |||
18d0f50094 | |||
e32e106ce3 | |||
4608a7a165 | |||
985be2de59 | |||
![]() |
f07d99efed | ||
4a55d826d9 | |||
2ee7a5b871 | |||
72321b8ec5 | |||
7226cc8218 | |||
55be5cd0e7 | |||
![]() |
3c4515e4e9 | ||
29eb34e1d5 | |||
d6f727a142 | |||
6d5fc465c7 | |||
ed0b5eb483 | |||
fd0296f801 | |||
58391802be | |||
e01b5a3098 | |||
bea0906f65 | |||
71a314b0a0 | |||
842667cdee | |||
e5bcc5cfc2 | |||
32508344fe | |||
ee3df7c3bf | |||
b18232615d | |||
83efc53edc | |||
f546e64c34 | |||
822aba9481 | |||
3007bf756c | |||
![]() |
87cd88232e | ||
832fd1d34b | |||
3ae7e4df70 | |||
55883172a1 | |||
b366fa1877 | |||
95eed07719 | |||
f15b17c936 | |||
5d6f53b37a | |||
23d017780f | |||
bee2997f90 | |||
80c5277936 | |||
![]() |
a9d4284b43 | ||
7ca9398766 | |||
bd4c935c4c | |||
b65ee230be | |||
8b4adfbe21 | |||
57e93f0b49 | |||
![]() |
be802aa5af | ||
d1a556b8c6 | |||
![]() |
83525159e3 | ||
4f8bf860e0 | |||
2ee0c679fb | |||
23a20b7ddb | |||
fd5ddc02b1 | |||
e3df7be3b5 | |||
65ecdd19ca | |||
d3f0d059f8 | |||
d572a8d2dc | |||
01a9c261f5 | |||
1cab607fa1 |
9
.gitmodules
vendored
9
.gitmodules
vendored
@ -1,3 +1,12 @@
|
||||
[submodule "app/libgocryptfs"]
|
||||
path = app/libgocryptfs
|
||||
url = https://forge.chapril.org/hardcoresushi/libgocryptfs.git
|
||||
[submodule "libpdfviewer"]
|
||||
path = libpdfviewer
|
||||
url = https://forge.chapril.org/hardcoresushi/libpdfviewer.git
|
||||
[submodule "app/libcryfs"]
|
||||
path = app/libcryfs
|
||||
url = https://forge.chapril.org/hardcoresushi/libcryfs.git
|
||||
[submodule "app/ffmpeg/ffmpeg"]
|
||||
path = app/ffmpeg/ffmpeg
|
||||
url = https://git.ffmpeg.org/ffmpeg.git
|
||||
|
99
BUILD.md
Normal file
99
BUILD.md
Normal file
@ -0,0 +1,99 @@
|
||||
# Introduction
|
||||
DroidFS relies on modified versions of the original encrypted filesystems programs to open volumes. [CryFS](https://github.com/cryfs/cryfs) is written in C++ while [gocryptfs](https://github.com/rfjakob/gocryptfs) is written in [Go](https://golang.org). Thus, building DroidFS requires the compilation of native code. However, for the sake of simplicity, the application has been designed in a modular way: you can build a version of DroidFS that supports both Gocryptfs and CryFS, or only one of the two.
|
||||
|
||||
Moreover, DroidFS aims to be accessible to as many people as possible. If you encounter any problems or need help with the build, feel free to open an issue, a discussion, or contact me (currently the main developer) by [email](mailto:gh@arkensys.dedyn.io) or on [Matrix](https://matrix.org): @hardcoresushi:matrix.underworld.fr
|
||||
|
||||
# Setup
|
||||
|
||||
The following two steps assume you're using a Debian-based Linux distribution. Package names might be similar for other distributions. Don't hesitate to ask if you're having trouble with this.
|
||||
|
||||
Install required packages:
|
||||
```
|
||||
$ sudo apt-get install openjdk-17-jdk-headless build-essential pkg-config git gnupg2 wget apksigner npm
|
||||
```
|
||||
You also need to manually install the [Android SDK](https://developer.android.com/studio/index.html#command-tools) and the [Android Native Development Kit (NDK)](https://github.com/android/ndk/wiki/Unsupported-Downloads#r25c) version `25.2.9519653` (r25c). libcryfs cannot be built with newer NDK versions at the moment due to compatibility issues with [boost](https://www.boost.org). If you succeed in building it with a more recent version of NDK, please report it.
|
||||
|
||||
If you want a support for Gocryptfs volumes, you need to install [Go](https://golang.org/doc/install):
|
||||
```
|
||||
$ sudo apt-get install golang-go
|
||||
```
|
||||
The code should be authenticated before being built. To verify the signatures, you will need my PGP key:
|
||||
```
|
||||
$ gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys AFE384344A45E13A
|
||||
```
|
||||
Fingerprint: `B64E FE86 CEE1 D054 F082 1711 AFE3 8434 4A45 E13A` \
|
||||
Email: `Hardcore Sushi <hardcore.sushi@disroot.org>`
|
||||
|
||||
# Download sources
|
||||
Download DroidFS source code:
|
||||
```
|
||||
$ git clone --depth=1 https://forge.chapril.org/hardcoresushi/DroidFS.git
|
||||
```
|
||||
Verify sources:
|
||||
```
|
||||
$ cd DroidFS
|
||||
$ git verify-commit HEAD
|
||||
```
|
||||
__Don't continue if the verification fails!__
|
||||
|
||||
Initialize submodules:
|
||||
```
|
||||
$ git submodule update --init
|
||||
```
|
||||
If you want Gocryptfs support, initliaze libgocryptfs submodules:
|
||||
```
|
||||
$ cd app/libgocryptfs
|
||||
$ git submodule update --init
|
||||
```
|
||||
If you want CryFS support, initialize libcryfs submodules:
|
||||
```
|
||||
$ cd app/libcryfs
|
||||
$ git submodule update --init
|
||||
```
|
||||
|
||||
# Build
|
||||
Retrieve your Android NDK installation path, usually something like `/home/\<user\>/Android/SDK/ndk/\<NDK version\>`. Then, make it available in your shell:
|
||||
```
|
||||
$ export ANDROID_NDK_HOME="<your ndk path>"
|
||||
```
|
||||
If you know your CPU ABI, you can specify it to build scripts in order to speed up compilation time. If you don't know it, or want to build for all ABIs, just leave the field blank.
|
||||
|
||||
Start by compiling FFmpeg:
|
||||
```
|
||||
$ cd app/ffmpeg
|
||||
$ ./build.sh [<ABI>]
|
||||
```
|
||||
## libgocryptfs
|
||||
This step is only required if you want Gocryptfs support.
|
||||
```
|
||||
$ cd app/libgocryptfs
|
||||
$ ./build.sh [<ABI>]
|
||||
```
|
||||
## Compile APKs
|
||||
Gradle build libgocryptfs and libcryfs by default.
|
||||
|
||||
To build DroidFS without Gocryptfs support, run:
|
||||
```
|
||||
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableGocryptfs=true
|
||||
```
|
||||
To build DroidFS without CryFS support, run:
|
||||
```
|
||||
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableCryFS=true
|
||||
```
|
||||
If you want to build DroidFS with support for both Gocryptfs and CryFS, just run:
|
||||
```
|
||||
$ ./gradlew assembleRelease [-Pabi=<ABI>]
|
||||
```
|
||||
|
||||
# Sign APKs
|
||||
If the build succeeds, you will find the unsigned APKs in `app/build/outputs/apk/release/`. These APKs need to be signed in order to be installed on an Android device.
|
||||
|
||||
If you don't already have a keystore, you can create a new one by running:
|
||||
```
|
||||
$ keytool -genkey -keystore <output file> -alias <key alias> -keyalg EC -validity 10000
|
||||
```
|
||||
Then, sign the APK with:
|
||||
```
|
||||
$ apksigner sign --out droidfs.apk -v --ks <keystore> app/build/outputs/apk/release/<unsigned apk file>
|
||||
```
|
||||
Now you can install `droidfs.apk` on your device.
|
20
DONATE.txt
Normal file
20
DONATE.txt
Normal 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-----
|
185
README.md
185
README.md
@ -1,57 +1,86 @@
|
||||
# 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.
|
||||
It currently only works with [gocryptfs](https://github.com/rfjakob/gocryptfs) but support for [CryFS](https://github.com/cryfs/cryfs) could be added in the future.
|
||||
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).
|
||||
|
||||
For mortals: Encrypted storage compatible with already existing softwares.
|
||||
|
||||
<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/2.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/4.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>
|
||||
|
||||
# Disclamer
|
||||
# Support
|
||||
The creator of DroidFS works as a freelance developer and privacy consultant. I am currently looking for new clients! If you are interested, take a look at the [website](https://arkensys.dedyn.io). Alternatively, you can directly support DroidFS by making a [donation](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/DONATE.txt).
|
||||
|
||||
Thank you so much ❤️.
|
||||
|
||||
# Disclaimer
|
||||
DroidFS is provided "as is", without any warranty of any kind.
|
||||
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.
|
||||
Do not use this app with volumes containing sensitive data unless you know exactly what you are doing.
|
||||
|
||||
# Features
|
||||
- Compatible with original encrypted volume implementations
|
||||
- Internal support for video, audio, images, text and PDF files
|
||||
- Built-in camera to take on-the-fly encrypted photos and videos
|
||||
- Unlocking volumes using fingerprint authentication
|
||||
- Volume auto-locking when the app goes in background
|
||||
|
||||
For planned features, see [TODO.md](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/TODO.md).
|
||||
|
||||
# Unsafe features
|
||||
DroidFS allows you to enable/disable unsafe features to fit your needs between security and comfort.
|
||||
It is strongly recommended to read the documentation of a feature before enabling it.
|
||||
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.
|
||||
|
||||
<ul>
|
||||
<li><h4>Allow screenshots:</h4>
|
||||
<li><b>Allow screenshots:</b>
|
||||
|
||||
Disable the secure flag of DroidFS activities. This will allow you to take screenshots from the app, but will also allow other apps to record the screen while using DroidFS.
|
||||
Note: apps with root access don't care about this flag: they can take screenshots or record the screen of any app without any permissions.
|
||||
</li>
|
||||
<li><h4>Allow opening files with other applications *:</h4>
|
||||
Decrypt and open file using external apps. These apps could save and send the files thus opened.
|
||||
</li>
|
||||
<li><h4>Allow exporting files:</h4>
|
||||
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.
|
||||
</li>
|
||||
<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.
|
||||
</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>
|
||||
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>
|
||||
|
||||
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><b>Allow exporting files:</b>
|
||||
|
||||
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.</li>
|
||||
<li><b>Allow sharing files via the android share menu⁽¹⁾:</b>
|
||||
|
||||
Decrypt and share file with other apps. These apps could save and send the files thus shared.</li>
|
||||
<li><b>Allow saving password hash using fingerprint:</b>
|
||||
|
||||
Generate an AES-256 GCM key in the Android Keystore (protected by fingerprint authentication), then use it to encrypt the volume password hash and store it to the DroidFS internal storage. This require Android v6.0+. If your device is not encrypted, extracting the encryption key with physical access may be possible.</li>
|
||||
<li><b>Disable volume auto-locking:</b> (previously called <i>"Keep volumes open when the app goes in background"</i>)
|
||||
|
||||
Don't close open volumes when you leave the app. Anyone going back to the application could have access to open volumes. Cryptographic secrets are kept in memory for an undefined amount of time.</li>
|
||||
<li><b>Keep volumes open:</b>
|
||||
(Different from the old <i>"Keep volumes open when the app goes in background"</i>. Yes it's confusing, sorry)
|
||||
|
||||
Keep the app running as a [foreground service](https://developer.android.com/develop/background-work/services/foreground-services) to maintain volumes open, even when the app is removed from recent tasks.
|
||||
|
||||
This avoid the app from being killed by the system during file operations or while accessing exposed volumes, but this mean cryptographic secrets stay in memory for an undefined amount of time.</li>
|
||||
<li><b>Allow opening files with other applications⁽¹⁾:</b>
|
||||
|
||||
Decrypt and open file using external apps. These apps could save and send the files thus opened.</li>
|
||||
<li><b>Expose open volumes⁽¹⁾:</b>
|
||||
|
||||
Allow open volumes to be browsed in the system file explorer (<a href="https://developer.android.com/guide/topics/providers/document-provider">DocumentProvider</a> API). Encrypted files can then be selected from other applications, potentially with permanent access. This feature requires <i>"Disable volume auto-locking"</i>, and works more reliably when <i>"Keep volumes open"</i> is also enabled.</li>
|
||||
<li><b>Grant write access:</b>
|
||||
|
||||
Files opened with another applications can be modified by them. This applies to both previous unsafe features.</li>
|
||||
</ul>
|
||||
* 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
|
||||
<a href="https://f-droid.org/packages/sushi.hardcore.droidfs">
|
||||
<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" height="75">
|
||||
</a>
|
||||
|
||||
You can download DroidFS from [F-Droid](https://f-droid.org/packages/sushi.hardcore.droidfs) or from the "Releases" section in the repo.
|
||||
You can download DroidFS from [F-Droid](https://f-droid.org/packages/sushi.hardcore.droidfs) or from the "Releases" section in this repository.
|
||||
|
||||
APKs availables here are signed with my PGP key available on keyservers:
|
||||
APKs available here are signed with my PGP key available on keyservers:
|
||||
|
||||
`gpg --keyserver hkp://pool.sks-keyservers.net --recv-keys 007F84120107191E` \
|
||||
Fingerprint: `BD5621479E7B74D36A405BE8007F84120107191E` \
|
||||
`gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys AFE384344A45E13A` \
|
||||
Fingerprint: `B64E FE86 CEE1 D054 F082 1711 AFE3 8434 4A45 E13A` \
|
||||
Email: `Hardcore Sushi <hardcore.sushi@disroot.org>`
|
||||
|
||||
To verify APKs, save the PGP-signed message to a file and run `gpg --verify <the file>`. __Don't install any APK if the verification fails !__
|
||||
@ -65,93 +94,33 @@ __Don't install the APK if the checksums don't match!__
|
||||
F-Droid APKs should be signed with the F-Droid key. More details [here](https://f-droid.org/docs/Release_Channels_and_Signing_Keys).
|
||||
|
||||
# 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>
|
||||
<li><h4>Read & write access to shared storage:</h4>
|
||||
Required for creating, opening and modifying volumes and for importing/exporting files to/from volumes.
|
||||
</li>
|
||||
<li><h4>Biometric/Fingerprint hardware:</h4>
|
||||
Required to encrypt/decrypt password hashes using a fingerprint protected key.
|
||||
</li>
|
||||
<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.
|
||||
</li>
|
||||
<li><h4>Record audio:</h4>
|
||||
Required if you want sound on video recorded with DroidFS.
|
||||
</li>
|
||||
</ul>
|
||||
- **Read & write access to shared storage**: Required to access volumes located on shared storage.
|
||||
- **Biometric/Fingerprint hardware**: Required to encrypt/decrypt password hashes using a fingerprint protected key.
|
||||
- **Camera**: Required to take encrypted photos or videos directly from the app.
|
||||
- **Record audio**: Required if you want sound on video recorded with DroidFS.
|
||||
- **Notifications**: Used to report file operations progress and notify about volumes kept open.
|
||||
|
||||
# Limitations
|
||||
DroidFS use some parts of the original gocryptfs code, which is designed to run on Linux x86 systems: it accesses the underlying file system with file paths and syscalls. However in Android, you can't access other apps files with file paths. Instead, you must use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. And obviously, the original gocryptfs code doesn't work with this API. This is why DroidFS can't open volumes provided by other applications, such as cloud storage clients. You can only use DroidFS with volumes located on shared storage or in the app's internal storage (hidden volumes). External storage such as SD cards are only supported in read-only access for now.
|
||||
DroidFS works as a wrapper around modified versions of the original encrypted container implementations ([libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) and [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs)). These programs were designed to run on standard x86 Linux systems: they access the underlying file system with file paths and syscalls. However, on Android, you can't access files from other applications using file paths. Instead, one has to use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. Obviously, neither Gocryptfs nor CryFS support this API. As a result, DroidFS cannot open volumes provided by other applications (such as cloud storage clients). If you want to synchronize your volumes on a cloud, the cloud application must synchronize the encrypted directory from disk.
|
||||
|
||||
# Build
|
||||
Most of the original gocryptfs code was used as is (written in Go) and compiled to native code. That's why you need [Go](https://golang.org) and the [Android Native Development Kit (NDK)](https://developer.android.com/ndk/) to build DroidFS from source.
|
||||
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.
|
||||
|
||||
|
||||
#### Install Requirements
|
||||
- [Android Studio](https://developer.android.com/studio/)
|
||||
- [Android NDK and CMake](https://developer.android.com/studio/projects/install-ndk) (OpenSSL build fails with NDK versions higher than v22. It should pass with NDK v21.4.7075529 and lower)
|
||||
- [Go](https://golang.org/doc/install) (on debian: `$ sudo apt-get install golang-go`)
|
||||
|
||||
#### Download Sources
|
||||
```
|
||||
$ git clone https://github.com/hardcore-sushi/DroidFS.git
|
||||
```
|
||||
Download [libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs):
|
||||
```
|
||||
$ cd DroidFS
|
||||
$ git submodule update --init
|
||||
```
|
||||
libgocryptfs needs OpenSSL:
|
||||
```
|
||||
$ cd app/libgocryptfs
|
||||
$ wget https://www.openssl.org/source/openssl-1.1.1l.tar.gz
|
||||
```
|
||||
Verify OpenSSL signature:
|
||||
```
|
||||
$ wget https://www.openssl.org/source/openssl-1.1.1l.tar.gz.asc
|
||||
$ gpg --verify openssl-1.1.1l.tar.gz.asc openssl-1.1.1l.tar.gz
|
||||
```
|
||||
Continue **ONLY** if the signature is **VALID**.
|
||||
```
|
||||
$ tar -xvzf openssl-1.1.1l.tar.gz
|
||||
```
|
||||
DroidFS also need [FFmpeg](https://ffmpeg.org) to record encrypted video:
|
||||
```
|
||||
$ cd app/ffmpeg
|
||||
$ git clone --depth=1 https://git.ffmpeg.org/ffmpeg.git
|
||||
```
|
||||
|
||||
#### Build
|
||||
First, we need to install some dependencies:
|
||||
```
|
||||
$ sudo apt-get install libcrypto++-dev libssl-dev pkg-config
|
||||
```
|
||||
And also Go dependencies:
|
||||
```
|
||||
$ go get golang.org/x/sys/unix golang.org/x/sys/cpu golang.org/x/crypto/hkdf github.com/jacobsa/crypto/siv github.com/rfjakob/eme
|
||||
```
|
||||
Then, retrieve your Android NDK installation path, usually someting like "/home/\<user\>/Android/SDK/ndk/\<NDK version\>". We can now build libgocryptfs:
|
||||
```
|
||||
$ cd DroidFS/app/libgocryptfs
|
||||
$ env ANDROID_NDK_HOME="<your ndk path>" OPENSSL_PATH="./openssl-1.1.1l" ./build.sh
|
||||
```
|
||||
And also FFmpeg:
|
||||
```
|
||||
$ cd app/ffmpeg
|
||||
$ env ANDROID_NDK_HOME="<your ndk path>" ./build.sh ffmpeg
|
||||
```
|
||||
Then, open the DroidFS project with Android Studio. \
|
||||
If a device (virtual or physical) is connected, just click on "Run". \
|
||||
If you want to generate a signed APK, you can follow this [post](https://stackoverflow.com/a/28938286).
|
||||
# Building from source
|
||||
You can follow the instructions in [BUILD.md](BUILD.md) to build DroidFS from source.
|
||||
|
||||
# Third party code
|
||||
Thanks to these open source projects that DroidFS uses:
|
||||
|
||||
### Modified code:
|
||||
- [gocryptfs](https://github.com/rfjakob/gocryptfs) to encrypt your data
|
||||
- Encrypted filesystems (to protect your data):
|
||||
- [libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) (forked from [gocryptfs](https://github.com/rfjakob/gocryptfs))
|
||||
- [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs) (forked from [CryFS](https://github.com/cryfs/cryfs))
|
||||
- [libpdfviewer](https://forge.chapril.org/hardcoresushi/libpdfviewer) (forked from [PdfViewer](https://github.com/GrapheneOS/PdfViewer)) to open PDF files
|
||||
- [DoubleTapPlayerView](https://github.com/vkay94/DoubleTapPlayerView) to add double-click controls to the video player
|
||||
### Borrowed code:
|
||||
- [MaterialFiles](https://github.com/zhanghai/MaterialFiles) for Kotlin natural sorting implementation
|
||||
### Libraries:
|
||||
- [Cyanea](https://github.com/jaredrummler/Cyanea) to customize UI
|
||||
- [Glide](https://github.com/bumptech/glide/) to display pictures
|
||||
- [Glide](https://github.com/bumptech/glide) to display pictures
|
||||
- [ExoPlayer](https://github.com/google/ExoPlayer) to play media files
|
||||
|
30
TODO.md
Normal file
30
TODO.md
Normal file
@ -0,0 +1,30 @@
|
||||
# TODO
|
||||
|
||||
Here's a list of features that it would be nice to have in DroidFS. As this is a FLOSS project, there are no special requirements on *when* or even *if* these features will be implemented, but contributions are greatly appreciated.
|
||||
|
||||
## Security
|
||||
- [hardened_malloc](https://github.com/GrapheneOS/hardened_malloc) compatibility ([#181](https://github.com/hardcore-sushi/DroidFS/issues/181))
|
||||
- Internal keyboard for passwords
|
||||
|
||||
## UX
|
||||
- File associations editor
|
||||
- Discovery before exporting
|
||||
- Making discovery before file operations optional
|
||||
- Modifiable scrypt parameters
|
||||
- Alert dialog showing details of file operations
|
||||
- Internal file browser to select volumes
|
||||
|
||||
## Encryption software support
|
||||
- [Shufflecake](https://shufflecake.net): plausible deniability for multiple hidden filesystems on Linux (would be absolutely awesome to have but quite difficult)
|
||||
- [fscrypt](https://www.kernel.org/doc/html/latest/filesystems/fscrypt.html): filesystem encryption at the kernel level
|
||||
|
||||
## Health
|
||||
- Remove all android:configChanges from AndroidManifest.xml
|
||||
- More efficient thumbnails cache
|
||||
- Guide for translators
|
||||
- Usage & code documentation
|
||||
- Automated tests
|
||||
|
||||
## And:
|
||||
- All the [feature requests on the GitHub repo](https://github.com/hardcore-sushi/DroidFS/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
|
||||
- All the [feature requests on the Gitea repo](https://forge.chapril.org/hardcoresushi/DroidFS/issues?q=&state=open&labels=748)
|
@ -1,27 +1,30 @@
|
||||
cmake_minimum_required(VERSION 3.10)
|
||||
|
||||
add_library(
|
||||
gocryptfs
|
||||
SHARED
|
||||
IMPORTED
|
||||
)
|
||||
project(DroidFS)
|
||||
|
||||
set_target_properties(
|
||||
gocryptfs
|
||||
PROPERTIES IMPORTED_LOCATION
|
||||
${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI}/libgocryptfs.so
|
||||
)
|
||||
option(GOCRYPTFS "build libgocryptfs" ON)
|
||||
option(CRYFS "build libcryfs" ON)
|
||||
|
||||
add_library(
|
||||
gocryptfs_jni
|
||||
SHARED
|
||||
src/main/native/gocryptfs_jni.c
|
||||
)
|
||||
add_library(memfile SHARED src/main/native/memfile.c)
|
||||
target_link_libraries(memfile log)
|
||||
|
||||
target_link_libraries(
|
||||
gocryptfs_jni
|
||||
gocryptfs
|
||||
)
|
||||
if (GOCRYPTFS)
|
||||
add_library(gocryptfs SHARED IMPORTED)
|
||||
set_target_properties(
|
||||
gocryptfs
|
||||
PROPERTIES IMPORTED_LOCATION
|
||||
${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI}/libgocryptfs.so
|
||||
)
|
||||
add_library(gocryptfs_jni SHARED src/main/native/gocryptfs_jni.c)
|
||||
target_include_directories(gocryptfs_jni PRIVATE ${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI})
|
||||
target_link_libraries(gocryptfs_jni gocryptfs)
|
||||
endif()
|
||||
|
||||
if (CRYFS)
|
||||
add_subdirectory(${PROJECT_SOURCE_DIR}/libcryfs)
|
||||
add_library(cryfs_jni SHARED src/main/native/libcryfs.c)
|
||||
target_link_libraries(cryfs_jni libcryfs-jni)
|
||||
endif()
|
||||
|
||||
add_library(
|
||||
avformat
|
||||
@ -65,14 +68,12 @@ add_library(
|
||||
src/main/native/libmux.c
|
||||
)
|
||||
|
||||
target_include_directories(mux PRIVATE ${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI})
|
||||
|
||||
target_link_libraries(
|
||||
mux
|
||||
avformat
|
||||
avcodec
|
||||
avutil
|
||||
)
|
||||
|
||||
include_directories(
|
||||
${PROJECT_SOURCE_DIR}/libgocryptfs/build/${ANDROID_ABI}
|
||||
${PROJECT_SOURCE_DIR}/ffmpeg/build/${ANDROID_ABI}
|
||||
log
|
||||
)
|
134
app/build.gradle
134
app/build.gradle
@ -1,49 +1,94 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
if (hasProperty("disableCryFS")) {
|
||||
ext.disableCryFS = getProperty("disableCryFS")
|
||||
} else {
|
||||
ext.disableCryFS = false
|
||||
}
|
||||
|
||||
if (hasProperty("disableGocryptfs")) {
|
||||
ext.disableGocryptfs = getProperty("disableGocryptfs")
|
||||
} else {
|
||||
ext.disableGocryptfs = false
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 31
|
||||
buildToolsVersion "31"
|
||||
ndkVersion "23.0.7599858"
|
||||
compileSdk 34
|
||||
ndkVersion '25.2.9519653'
|
||||
namespace "sushi.hardcore.droidfs"
|
||||
|
||||
compileOptions {
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
def abiCodes = [ "x86": 1, "x86_64": 2, "armeabi-v7a": 3, "arm64-v8a": 4]
|
||||
|
||||
defaultConfig {
|
||||
applicationId "sushi.hardcore.droidfs"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 29
|
||||
versionCode 19
|
||||
versionName "1.6.0"
|
||||
targetSdkVersion 34
|
||||
versionCode 37
|
||||
versionName "2.2.0"
|
||||
|
||||
ndk {
|
||||
abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
|
||||
}
|
||||
}
|
||||
|
||||
if (!file("fdroid").exists()) {
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
universalApk true
|
||||
reset() // fix unknown bug (https://ru.stackoverflow.com/questions/1557805/abis-armeabi-mips-mips64-riscv64-are-not-supported-for-platform)
|
||||
if (project.hasProperty("abi")) {
|
||||
include project.getProperty("abi")
|
||||
} else {
|
||||
abiCodes.keySet().each { abi -> include abi }
|
||||
universalApk !project.hasProperty("nouniversal")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild.cmake {
|
||||
if (project.ext.disableGocryptfs) {
|
||||
arguments "-DGOCRYPTFS=OFF"
|
||||
}
|
||||
if (project.ext.disableCryFS) {
|
||||
arguments "-DCRYFS=OFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all { variant ->
|
||||
applicationVariants.configureEach { variant ->
|
||||
variant.resValue "string", "versionName", variant.versionName
|
||||
buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}"
|
||||
buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}"
|
||||
variant.outputs.each { output ->
|
||||
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
if (abi == null) { // universal
|
||||
output.versionCodeOverride = variant.versionCode*10
|
||||
output.outputFileName = "DroidFS-v${variant.versionName}-${variant.name}-universal.apk"
|
||||
} else {
|
||||
output.versionCodeOverride = variant.versionCode*10 + abiCodes[abi]
|
||||
output.outputFileName = "DroidFS-v${variant.versionName}-${variant.name}-${abi}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
postprocessing {
|
||||
removeUnusedCode true
|
||||
removeUnusedResources true
|
||||
obfuscate false
|
||||
optimizeCode true
|
||||
proguardFiles 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,32 +97,45 @@ android {
|
||||
path file('CMakeLists.txt')
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java {
|
||||
exclude 'androidx/camera/video/originals/**'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: "libs", include: ["*.jar"])
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "androidx.core:core-ktx:1.6.0"
|
||||
implementation "androidx.appcompat:appcompat:1.3.1"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.1.1"
|
||||
implementation project(":libpdfviewer:app")
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||
def lifecycle_version = "2.8.3"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
|
||||
|
||||
implementation "androidx.sqlite:sqlite-ktx:2.1.0"
|
||||
implementation "androidx.preference:preference-ktx:1.1.1"
|
||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||
implementation "com.jaredrummler:cyanea:1.0.2"
|
||||
implementation "com.github.bumptech.glide:glide:4.12.0"
|
||||
implementation "androidx.biometric:biometric:1.1.0"
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
def exoplayer_version = "2.15.1"
|
||||
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
|
||||
implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
|
||||
def media3_version = "1.3.1"
|
||||
implementation "androidx.media3:media3-exoplayer:$media3_version"
|
||||
implementation "androidx.media3:media3-ui:$media3_version"
|
||||
implementation "androidx.media3:media3-datasource:$media3_version"
|
||||
|
||||
implementation "androidx.concurrent:concurrent-futures:1.1.0"
|
||||
def camerax_version = "1.3.4"
|
||||
implementation "androidx.camera:camera-camera2:$camerax_version"
|
||||
implementation "androidx.camera:camera-lifecycle:$camerax_version"
|
||||
implementation "androidx.camera:camera-view:$camerax_version"
|
||||
implementation "androidx.camera:camera-extensions:$camerax_version"
|
||||
|
||||
def camerax_v1 = "1.1.0-alpha10"
|
||||
implementation "androidx.camera:camera-camera2:$camerax_v1"
|
||||
implementation "androidx.camera:camera-lifecycle:$camerax_v1"
|
||||
def camerax_v2 = "1.0.0-alpha30"
|
||||
implementation "androidx.camera:camera-view:$camerax_v2"
|
||||
implementation "androidx.camera:camera-extensions:$camerax_v2"
|
||||
// dependencies needed by CameraX patch
|
||||
implementation "androidx.concurrent:concurrent-futures:1.2.0"
|
||||
def autoValueVersion = '1.10.4'
|
||||
implementation "com.google.auto.value:auto-value-annotations:$autoValueVersion"
|
||||
annotationProcessor "com.google.auto.value:auto-value:$autoValueVersion"
|
||||
}
|
||||
|
1
app/ffmpeg/.gitignore
vendored
1
app/ffmpeg/.gitignore
vendored
@ -1,2 +1 @@
|
||||
ffmpeg
|
||||
build
|
||||
|
@ -1,11 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z ${ANDROID_NDK_HOME+x} ]; then
|
||||
echo "Error: \$ANDROID_NDK_HOME is not defined."
|
||||
elif [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <FFmpeg source directory> [<ABI>]"
|
||||
echo "Error: \$ANDROID_NDK_HOME is not defined." >&2
|
||||
exit 1
|
||||
else
|
||||
FFMPEG_DIR=$1
|
||||
cd "$(dirname "$0")"
|
||||
FFMPEG_DIR="ffmpeg"
|
||||
compile_for_arch() {
|
||||
echo "Compiling for $1..."
|
||||
case $1 in
|
||||
@ -27,7 +29,8 @@ else
|
||||
ARCH="arm"
|
||||
;;
|
||||
esac
|
||||
(cd $FFMPEG_DIR && make clean;
|
||||
(cd $FFMPEG_DIR
|
||||
make clean || true
|
||||
./configure \
|
||||
--cc="$CFN" \
|
||||
--cxx="$CFN++" \
|
||||
@ -61,6 +64,7 @@ else
|
||||
--disable-sndio \
|
||||
--disable-schannel \
|
||||
--disable-securetransport \
|
||||
--disable-vulkan \
|
||||
--disable-xlib \
|
||||
--disable-zlib \
|
||||
--disable-cuvid \
|
||||
@ -70,21 +74,19 @@ else
|
||||
--disable-audiotoolbox \
|
||||
--disable-appkit \
|
||||
--disable-alsa \
|
||||
--disable-debug \
|
||||
>/dev/null &&
|
||||
make -j $(nproc --all) >/dev/null) &&
|
||||
mkdir -p build/$1/libavformat build/$1/libavcodec build/$1/libavutil &&
|
||||
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so build/$1/libavformat &&
|
||||
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so build/$1/libavcodec &&
|
||||
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so build/$1/libavutil
|
||||
--disable-debug
|
||||
make -j "$(nproc --all)" >/dev/null)
|
||||
mkdir -p "build/$1/libavformat" "build/$1/libavcodec" "build/$1/libavutil"
|
||||
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so "build/$1/libavformat"
|
||||
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so "build/$1/libavcodec"
|
||||
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so "build/$1/libavutil"
|
||||
}
|
||||
|
||||
export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
|
||||
if [ $# -eq 2 ]; then
|
||||
compile_for_arch $2
|
||||
if [ $# -eq 1 ]; then
|
||||
compile_for_arch "$1"
|
||||
else
|
||||
declare -a ABIs=("x86_64" "x86" "arm64-v8a" "armeabi-v7a")
|
||||
for abi in ${ABIs[@]}; do
|
||||
for abi in "x86_64" "x86" "arm64-v8a" "armeabi-v7a"; do
|
||||
compile_for_arch $abi
|
||||
done
|
||||
fi
|
||||
|
1
app/ffmpeg/ffmpeg
Submodule
1
app/ffmpeg/ffmpeg
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit af25a4bfd2503caf3ee485b27b99b620302f5718
|
1
app/libcryfs
Submodule
1
app/libcryfs
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit cd0af7088066f870f12eceed9836bde897f1d164
|
@ -1 +1 @@
|
||||
Subproject commit bd5d53f50eb274ed084e9f363ccfbe30d9061039
|
||||
Subproject commit b221d4cf3c70edc711169a769a476802d2577b2b
|
33
app/proguard-rules.pro
vendored
33
app/proguard-rules.pro
vendored
@ -1,31 +1,18 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# 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
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
-keep class sushi.hardcore.droidfs.SettingsActivity$**
|
||||
-keep class sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
-keepclassmembers class sushi.hardcore.droidfs.explorers.ExplorerElement {
|
||||
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 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;
|
||||
}
|
||||
|
@ -1,13 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="sushi.hardcore.droidfs"
|
||||
android:installLocation="auto">
|
||||
|
||||
<permission
|
||||
android:name="${applicationId}.WRITE_TEMPORARY_STORAGE"
|
||||
android:protectionLevel="signature" />
|
||||
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
@ -25,81 +24,66 @@
|
||||
tools:node="remove" /> <!--removing this permission automatically added by exoplayer-->
|
||||
|
||||
<application
|
||||
android:name=".ColoredApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/icon_launcher"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".CameraActivity"
|
||||
android:screenOrientation="nosensor" />
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:label="@string/title_activity_settings"
|
||||
android:parentActivityName=".MainActivity" />
|
||||
<activity android:name=".explorers.ExplorerActivity" />
|
||||
<activity android:name=".explorers.ExplorerActivityPick" />
|
||||
<activity android:name=".explorers.ExplorerActivityDrop" />
|
||||
<activity
|
||||
android:name=".OpenActivity"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:screenOrientation="nosensor"
|
||||
android:windowSoftInputMode="adjustPan">
|
||||
android:theme="@style/BaseTheme"
|
||||
android:name=".VolumeManagerApp"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/backup_rules">
|
||||
<activity android:name=".MainActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter android:label="@string/share_menu_label">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".CreateActivity"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:screenOrientation="nosensor" />
|
||||
<activity
|
||||
android:name=".ChangePasswordActivity"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:screenOrientation="nosensor"
|
||||
android:windowSoftInputMode="adjustPan" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:screenOrientation="nosensor">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<activity android:name=".SettingsActivity" android:label="@string/title_activity_settings"/>
|
||||
<activity android:name=".add_volume.AddVolumeActivity" android:windowSoftInputMode="adjustResize"/>
|
||||
<activity android:name=".ChangePasswordActivity" android:windowSoftInputMode="adjustResize"/>
|
||||
<activity android:name=".explorers.ExplorerActivity"/>
|
||||
<activity android:name=".explorers.ExplorerActivityPick"/>
|
||||
<activity android:name=".explorers.ExplorerActivityDrop"/>
|
||||
<activity android:name=".file_viewers.ImageViewer"/>
|
||||
<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.AudioPlayer" android:configChanges="screenSize|orientation" />
|
||||
<activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" />
|
||||
<activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
|
||||
<activity android:name=".LogcatActivity"/>
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".file_viewers.ImageViewer"
|
||||
android:configChanges="screenSize|orientation" /> <!-- don't reload content on configuration change -->
|
||||
<activity
|
||||
android:name=".file_viewers.VideoPlayer"
|
||||
android:configChanges="screenSize|orientation" />
|
||||
<activity
|
||||
android:name=".file_viewers.AudioPlayer"
|
||||
android:configChanges="screenSize|orientation" />
|
||||
<activity
|
||||
android:name=".file_viewers.TextEditor"
|
||||
android:configChanges="screenSize|orientation" />
|
||||
<service android:name=".KeepAliveService" android:exported="false" android:foregroundServiceType="dataSync" />
|
||||
<service android:name=".ClosingService" android:exported="false" android:stopWithTask="false"/>
|
||||
<service android:name=".file_operations.FileOperationService" android:exported="false" android:foregroundServiceType="dataSync"/>
|
||||
|
||||
<service android:name=".file_operations.FileOperationService" android:exported="false"/>
|
||||
|
||||
<receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false">
|
||||
<receiver android:name=".NotificationBroadcastReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="file_operation_cancel"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name=".content_providers.RestrictedFileProvider"
|
||||
android:name=".content_providers.TemporaryFileProvider"
|
||||
android:authorities="${applicationId}.temporary_provider"
|
||||
android:exported="true"/>
|
||||
|
||||
<provider
|
||||
android:authorities="${applicationId}.volume_provider"
|
||||
android:name=".content_providers.VolumeProvider"
|
||||
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>
|
||||
|
||||
</manifest>
|
||||
|
14
app/src/main/java/androidx/camera/video/MediaMuxer.kt
Normal file
14
app/src/main/java/androidx/camera/video/MediaMuxer.kt
Normal 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()
|
||||
}
|
@ -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
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
3194
app/src/main/java/androidx/camera/video/SucklessRecorder.java
Normal file
3194
app/src/main/java/androidx/camera/video/SucklessRecorder.java
Normal file
File diff suppressed because it is too large
Load Diff
263
app/src/main/java/androidx/camera/video/SucklessRecording.java
Normal file
263
app/src/main/java/androidx/camera/video/SucklessRecording.java
Normal 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
20
app/src/main/java/androidx/camera/video/originals/README.md
Normal file
20
app/src/main/java/androidx/camera/video/originals/README.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Update the modified CameraX files to a new upstream version:
|
||||
|
||||
Create the `new` folder if needed:
|
||||
```
|
||||
mkdir -p new
|
||||
```
|
||||
|
||||
Put the new CameraX files from upstream (`androidx.camera.video.Recorder`, `androidx.camera.video.Recording`, `androidx.camera.video.PendingRecording` and `androidx.camera.video.internal.encoder.EncoderImpl`) in the `new` folder.
|
||||
|
||||
Perform the 3 way merge:
|
||||
```
|
||||
./merge.sh
|
||||
```
|
||||
|
||||
If new files are created in the current directory, they contains conflicts. Resolve them then move them to the right location.
|
||||
|
||||
Finally, update the base:
|
||||
```
|
||||
./update.sh
|
||||
```
|
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
|
3361
app/src/main/java/androidx/camera/video/originals/base/Recorder.java
Normal file
3361
app/src/main/java/androidx/camera/video/originals/base/Recorder.java
Normal file
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
}
|
||||
}
|
||||
|
8
app/src/main/java/androidx/camera/video/originals/merge.sh
Executable file
8
app/src/main/java/androidx/camera/video/originals/merge.sh
Executable 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
|
3
app/src/main/java/androidx/camera/video/originals/update.sh
Executable file
3
app/src/main/java/androidx/camera/video/originals/update.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
rm -r base && mv new base
|
@ -3,36 +3,23 @@ package sushi.hardcore.droidfs
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.jaredrummler.cyanea.app.CyaneaAppCompatActivity
|
||||
import sushi.hardcore.droidfs.widgets.ThemeColor
|
||||
|
||||
open class BaseActivity: CyaneaAppCompatActivity() {
|
||||
open class BaseActivity: AppCompatActivity() {
|
||||
protected lateinit var sharedPrefs: SharedPreferences
|
||||
protected var isRecreating = false
|
||||
protected var applyCustomTheme: Boolean = true
|
||||
lateinit var theme: Theme
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val themeColor = ThemeColor.getThemeColor(this)
|
||||
if (cyanea.accent != themeColor){
|
||||
changeThemeColor(themeColor)
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
if (!sharedPrefs.getBoolean("usf_screenshot", false)){
|
||||
theme = Theme.fromSharedPrefs(sharedPrefs)
|
||||
if (applyCustomTheme) {
|
||||
setTheme(theme.toResourceId())
|
||||
}
|
||||
if (!sharedPrefs.getBoolean("usf_screenshot", false)) {
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
fun changeThemeColor(themeColor: Int? = null){
|
||||
val accentColor = themeColor ?: ThemeColor.getThemeColor(this)
|
||||
val backgroundColor = ContextCompat.getColor(this, R.color.backgroundColor)
|
||||
isRecreating = true
|
||||
cyanea.edit{
|
||||
accent(accentColor)
|
||||
//accentDark(themeColor)
|
||||
//accentLight(themeColor)
|
||||
background(backgroundColor)
|
||||
//backgroundDark(backgroundColor)
|
||||
//backgroundLight(backgroundColor)
|
||||
}.recreate(this)
|
||||
}
|
||||
}
|
@ -2,46 +2,67 @@ package sushi.hardcore.droidfs
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.util.Size
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.Surface
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.view.animation.RotateAnimation
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.RelativeLayout
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.camera.camera2.interop.Camera2CameraInfo
|
||||
import androidx.camera.core.*
|
||||
//import androidx.camera.core.VideoCapture
|
||||
import androidx.camera.core.AspectRatio
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.DynamicRange
|
||||
import androidx.camera.core.FocusMeteringAction
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.UseCase
|
||||
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||
import androidx.camera.extensions.ExtensionMode
|
||||
import androidx.camera.extensions.ExtensionsManager
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.MuxerOutputOptions
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import androidx.camera.video.SucklessRecorder
|
||||
import androidx.camera.video.SucklessRecording
|
||||
import androidx.camera.video.VideoCapture
|
||||
import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import sushi.hardcore.droidfs.adapters.DialogSingleChoiceAdapter
|
||||
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.databinding.ActivityCameraBinding
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.util.finishOnClose
|
||||
import sushi.hardcore.droidfs.video_recording.AsynchronousSeekableWriter
|
||||
import sushi.hardcore.droidfs.video_recording.FFmpegMuxer
|
||||
import sushi.hardcore.droidfs.video_recording.SeekableWriter
|
||||
import sushi.hardcore.droidfs.video_recording.VideoCapture
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import java.io.*
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.Random
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
companion object {
|
||||
private const val CAMERA_PERMISSION_REQUEST_CODE = 0
|
||||
@ -61,14 +82,12 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
binding.imageTimer.setImageResource(R.drawable.icon_timer_off)
|
||||
}
|
||||
}
|
||||
private var usf_keep_open = false
|
||||
private lateinit var sensorOrientationListener: SensorOrientationListener
|
||||
private var currentRotation = 0
|
||||
private var previousOrientation: Float = 0f
|
||||
private lateinit var orientedIcons: List<ImageView>
|
||||
private lateinit var gocryptfsVolume: GocryptfsVolume
|
||||
private lateinit var encryptedVolume: EncryptedVolume
|
||||
private lateinit var outputDirectory: String
|
||||
private var isFinishingIntentionally = false
|
||||
private var isAskingPermissions = false
|
||||
private var permissionsGranted = false
|
||||
private lateinit var executor: Executor
|
||||
private lateinit var cameraProvider: ProcessCameraProvider
|
||||
@ -76,29 +95,39 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
private lateinit var cameraSelector: CameraSelector
|
||||
private val cameraPreview = Preview.Builder().build()
|
||||
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 resolutions: List<Size>? = null
|
||||
private var currentResolutionIndex: Int = 0
|
||||
private var currentResolution: Size? = null
|
||||
private val aspectRatios = arrayOf(AspectRatio.RATIO_16_9, AspectRatio.RATIO_4_3)
|
||||
private var currentAspectRatioIndex = 0
|
||||
private var qualities: List<Quality>? = null
|
||||
private var currentQualityIndex = -1
|
||||
private var captureMode = ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
|
||||
private var isBackCamera = true
|
||||
private var isInVideoMode = false
|
||||
private var isRecording = false
|
||||
private var isWaitingForTimer = false
|
||||
private lateinit var binding: ActivityCameraBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false)
|
||||
binding = ActivityCameraBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
gocryptfsVolume = GocryptfsVolume(intent.getIntExtra("sessionID", -1))
|
||||
supportActionBar?.hide()
|
||||
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
|
||||
intent.getIntExtra("volumeId", -1)
|
||||
)!!
|
||||
finishOnClose(encryptedVolume)
|
||||
outputDirectory = intent.getStringExtra("path")!!
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED){
|
||||
permissionsGranted = true
|
||||
} else {
|
||||
isAskingPermissions = true
|
||||
requestPermissions(arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
} else {
|
||||
@ -110,112 +139,153 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
ProcessCameraProvider.getInstance(this).apply {
|
||||
addListener({
|
||||
cameraProvider = get()
|
||||
setupCamera()
|
||||
}, executor)
|
||||
}
|
||||
ExtensionsManager.getInstance(this).apply {
|
||||
addListener({
|
||||
extensionsManager = get()
|
||||
setupCamera()
|
||||
ExtensionsManager.getInstanceAsync(this@CameraActivity, cameraProvider).apply {
|
||||
addListener({
|
||||
extensionsManager = get()
|
||||
setupCamera()
|
||||
}, executor)
|
||||
}
|
||||
}, executor)
|
||||
}
|
||||
|
||||
binding.imageCaptureMode.setOnClickListener {
|
||||
val currentIndex = if (captureMode == ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.camera_optimization)
|
||||
.setSingleChoiceItems(DialogSingleChoiceAdapter(this, arrayOf(R.string.maximize_quality, R.string.minimize_latency).map { getString(it) }), currentIndex) { dialog, which ->
|
||||
val resId: Int
|
||||
val newCaptureMode = if (which == 0) {
|
||||
resId = R.drawable.icon_high_quality
|
||||
ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
|
||||
} else {
|
||||
resId = R.drawable.icon_speed
|
||||
ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
|
||||
}
|
||||
if (newCaptureMode != captureMode) {
|
||||
captureMode = newCaptureMode
|
||||
binding.imageCaptureMode.setImageResource(resId)
|
||||
setupCamera()
|
||||
}
|
||||
dialog.dismiss()
|
||||
if (isInVideoMode) {
|
||||
qualities?.let { qualities ->
|
||||
val qualityNames = qualities.map {
|
||||
when (it) {
|
||||
Quality.UHD -> "UHD"
|
||||
Quality.FHD -> "FHD"
|
||||
Quality.HD -> "HD"
|
||||
Quality.SD -> "SD"
|
||||
else -> throw IllegalArgumentException("Invalid quality: $it")
|
||||
}
|
||||
}.toTypedArray()
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle("Choose quality:")
|
||||
.setSingleChoiceItems(qualityNames, currentQualityIndex) { dialog, which ->
|
||||
currentQualityIndex = which
|
||||
rebindUseCases()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
binding.imageRatio.setOnClickListener {
|
||||
resolutions?.let {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.choose_resolution)
|
||||
.setSingleChoiceItems(DialogSingleChoiceAdapter(this, it.map { size -> size.toString() }), currentResolutionIndex) { dialog, which ->
|
||||
setupCamera(resolutions!![which])
|
||||
} else {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.camera_optimization)
|
||||
.setSingleChoiceItems(
|
||||
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) {
|
||||
ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
|
||||
} else {
|
||||
ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
|
||||
}
|
||||
if (newCaptureMode != captureMode) {
|
||||
captureMode = newCaptureMode
|
||||
setCaptureModeIcon()
|
||||
rebindUseCases()
|
||||
}
|
||||
dialog.dismiss()
|
||||
currentResolutionIndex = which
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
binding.imageTimer.setOnClickListener {
|
||||
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null)
|
||||
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text)
|
||||
dialogEditText.inputType = InputType.TYPE_CLASS_NUMBER
|
||||
val dialog = ColoredAlertDialogBuilder(this)
|
||||
.setView(dialogEditTextView)
|
||||
.setTitle(getString(R.string.enter_timer_duration))
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
val enteredValue = dialogEditText.text.toString()
|
||||
if (enteredValue.isEmpty()){
|
||||
Toast.makeText(this, getString(R.string.timer_empty_error_msg), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
timerDuration = enteredValue.toInt()
|
||||
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 {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.choose_resolution)
|
||||
.setSingleChoiceItems(it.map { size -> size.toString() }.toTypedArray(), currentResolutionIndex) { dialog, which ->
|
||||
currentResolution = resolutions!![which]
|
||||
currentResolutionIndex = which
|
||||
rebindUseCases()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create()
|
||||
dialogEditText.setOnEditorActionListener { _, _, _ ->
|
||||
timerDuration = dialogEditText.text.toString().toInt()
|
||||
dialog.dismiss()
|
||||
true
|
||||
}
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
dialog.show()
|
||||
}
|
||||
binding.imageTimer.setOnClickListener {
|
||||
with (EditTextDialog(this, R.string.enter_timer_duration) {
|
||||
try {
|
||||
timerDuration = it.toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
Toast.makeText(this, R.string.invalid_number, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
binding.dialogEditText.inputType = InputType.TYPE_CLASS_NUMBER
|
||||
show()
|
||||
}
|
||||
}
|
||||
binding.imageFlash.setOnClickListener {
|
||||
binding.imageFlash.setImageResource(when (imageCapture?.flashMode) {
|
||||
ImageCapture.FLASH_MODE_AUTO -> {
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_ON
|
||||
R.drawable.icon_flash_on
|
||||
binding.imageFlash.setImageResource(if (isInVideoMode) {
|
||||
when (imageCapture?.flashMode) {
|
||||
ImageCapture.FLASH_MODE_ON -> {
|
||||
camera?.cameraControl?.enableTorch(false)
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
|
||||
R.drawable.icon_flash_off
|
||||
}
|
||||
else -> {
|
||||
camera?.cameraControl?.enableTorch(true)
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_ON
|
||||
R.drawable.icon_flash_on
|
||||
}
|
||||
}
|
||||
ImageCapture.FLASH_MODE_ON -> {
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
|
||||
R.drawable.icon_flash_off
|
||||
}
|
||||
else -> {
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_AUTO
|
||||
R.drawable.icon_flash_auto
|
||||
} else {
|
||||
when (imageCapture?.flashMode) {
|
||||
ImageCapture.FLASH_MODE_AUTO -> {
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_ON
|
||||
R.drawable.icon_flash_on
|
||||
}
|
||||
ImageCapture.FLASH_MODE_ON -> {
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
|
||||
R.drawable.icon_flash_off
|
||||
}
|
||||
else -> {
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_AUTO
|
||||
R.drawable.icon_flash_auto
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
binding.imageModeSwitch.setOnClickListener {
|
||||
isInVideoMode = !isInVideoMode
|
||||
if (isInVideoMode) {
|
||||
rebindUseCases()
|
||||
binding.imageFlash.setImageResource(if (isInVideoMode) {
|
||||
binding.recordVideoButton.visibility = View.VISIBLE
|
||||
binding.takePhotoButton.visibility = View.GONE
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
isAskingPermissions = true
|
||||
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), AUDIO_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
}
|
||||
binding.imageModeSwitch.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.icon_photo)?.mutate()?.also {
|
||||
it.setTint(ContextCompat.getColor(this, R.color.neutralIconTint))
|
||||
})
|
||||
setCaptureModeIcon()
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
|
||||
R.drawable.icon_flash_off
|
||||
} else {
|
||||
binding.recordVideoButton.visibility = View.GONE
|
||||
binding.takePhotoButton.visibility = View.VISIBLE
|
||||
}
|
||||
binding.imageModeSwitch.setImageResource(R.drawable.icon_video)
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_AUTO
|
||||
R.drawable.icon_flash_auto
|
||||
})
|
||||
}
|
||||
binding.imageCameraSwitch.setOnClickListener {
|
||||
isBackCamera = if (isBackCamera) {
|
||||
@ -223,8 +293,15 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
false
|
||||
} else {
|
||||
binding.imageCameraSwitch.setImageResource(R.drawable.icon_camera_front)
|
||||
if (isInVideoMode) {
|
||||
//reset flash state
|
||||
imageCapture?.flashMode = ImageCapture.FLASH_MODE_OFF
|
||||
binding.imageFlash.setImageResource(R.drawable.icon_flash_off)
|
||||
}
|
||||
true
|
||||
}
|
||||
resolutions = null
|
||||
qualities = null
|
||||
setupCamera()
|
||||
}
|
||||
binding.takePhotoButton.onClick = ::onClickTakePhoto
|
||||
@ -259,21 +336,17 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
isAskingPermissions = false
|
||||
if (grantResults.size == 1) {
|
||||
when (requestCode) {
|
||||
CAMERA_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
permissionsGranted = true
|
||||
setupCamera()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.camera_perm_needed)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
isFinishingIntentionally = true
|
||||
finish()
|
||||
}.show()
|
||||
.setPositiveButton(R.string.ok) { _, _ -> finish() }.show()
|
||||
}
|
||||
AUDIO_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
if (videoCapture != null) {
|
||||
@ -285,171 +358,229 @@ 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) {
|
||||
val screenWidth = resources.displayMetrics.widthPixels
|
||||
binding.cameraPreview.layoutParams = if (screenWidth < resolution.width) {
|
||||
RelativeLayout.LayoutParams(
|
||||
screenWidth,
|
||||
(resolution.height * (screenWidth.toFloat() / resolution.width)).toInt()
|
||||
)
|
||||
} else {
|
||||
RelativeLayout.LayoutParams(resolution.width, resolution.height)
|
||||
val screenHeight = resources.displayMetrics.heightPixels
|
||||
|
||||
var height = (resolution.height * (screenWidth.toFloat() / resolution.width)).toInt()
|
||||
var width = screenWidth
|
||||
if (height > screenHeight) {
|
||||
width = (width * (screenHeight.toFloat() / height)).toInt()
|
||||
height = screenHeight
|
||||
}
|
||||
binding.cameraPreview.layoutParams = RelativeLayout.LayoutParams(width, height).apply {
|
||||
addRule(RelativeLayout.CENTER_IN_PARENT)
|
||||
}
|
||||
(binding.cameraPreview.layoutParams as RelativeLayout.LayoutParams).addRule(RelativeLayout.CENTER_IN_PARENT)
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private fun setupCamera(resolution: Size? = null){
|
||||
if (permissionsGranted && ::extensionsManager.isInitialized && ::cameraProvider.isInitialized) {
|
||||
imageCapture = ImageCapture.Builder()
|
||||
.setCaptureMode(captureMode)
|
||||
.setFlashMode(imageCapture?.flashMode ?: ImageCapture.FLASH_MODE_AUTO)
|
||||
.apply {
|
||||
resolution?.let {
|
||||
setTargetResolution(it)
|
||||
private fun refreshImageCapture() {
|
||||
imageCapture = ImageCapture.Builder()
|
||||
.setCaptureMode(captureMode)
|
||||
.setFlashMode(imageCapture?.flashMode ?: ImageCapture.FLASH_MODE_AUTO)
|
||||
.setResolutionSelector(ResolutionSelector.Builder().setResolutionFilter { supportedSizes, _ ->
|
||||
resolutions = supportedSizes.sortedBy {
|
||||
-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))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
supportedSizes
|
||||
}.build())
|
||||
.setTargetRotation(currentRotation)
|
||||
.build()
|
||||
}
|
||||
|
||||
videoCapture = VideoCapture.Builder().apply {
|
||||
resolution?.let {
|
||||
setTargetResolution(it)
|
||||
}
|
||||
}.build()
|
||||
|
||||
cameraSelector = if (isBackCamera){ CameraSelector.DEFAULT_BACK_CAMERA } else { CameraSelector.DEFAULT_FRONT_CAMERA }
|
||||
if (extensionsManager.isExtensionAvailable(cameraProvider, cameraSelector, ExtensionMode.HDR)) {
|
||||
cameraSelector = extensionsManager.getExtensionEnabledCameraSelector(cameraProvider, cameraSelector, ExtensionMode.HDR)
|
||||
}
|
||||
|
||||
cameraProvider.unbindAll()
|
||||
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture, videoCapture)
|
||||
|
||||
adaptPreviewSize(resolution ?: imageCapture!!.attachedSurfaceResolution!!.swap())
|
||||
|
||||
if (resolutions == null) {
|
||||
val info = Camera2CameraInfo.from(camera!!.cameraInfo)
|
||||
val cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
|
||||
val characteristics = cameraManager.getCameraCharacteristics(info.cameraId)
|
||||
characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)?.let { streamConfigurationMap ->
|
||||
resolutions = streamConfigurationMap.getOutputSizes(imageCapture!!.imageFormat).map { it.swap() }
|
||||
}
|
||||
}
|
||||
private fun refreshVideoCapture() {
|
||||
val recorderBuilder = SucklessRecorder.Builder()
|
||||
.setExecutor(executor)
|
||||
.setAspectRatio(aspectRatios[currentAspectRatioIndex])
|
||||
if (currentQualityIndex != -1) {
|
||||
recorderBuilder.setQualitySelector(QualitySelector.from(qualities!![currentQualityIndex]))
|
||||
}
|
||||
videoRecorder = recorderBuilder.build()
|
||||
videoCapture = VideoCapture.withOutput(videoRecorder!!).apply {
|
||||
targetRotation = currentRotation
|
||||
}
|
||||
}
|
||||
|
||||
private fun takePhoto(outputPath: String) {
|
||||
val imageCapture = imageCapture ?: return
|
||||
val outputBuff = ByteArrayOutputStream()
|
||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(outputBuff).build()
|
||||
imageCapture.takePicture(outputOptions, executor, object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
binding.takePhotoButton.onPhotoTaken()
|
||||
if (gocryptfsVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), outputPath)){
|
||||
Toast.makeText(applicationContext, getString(R.string.picture_save_success, outputPath), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this@CameraActivity)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.picture_save_failed)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
isFinishingIntentionally = true
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
private fun rebindUseCases(): UseCase {
|
||||
cameraProvider.unbindAll()
|
||||
val currentUseCase = (if (isInVideoMode) {
|
||||
refreshVideoCapture()
|
||||
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture)
|
||||
if (qualities == null) {
|
||||
qualities = SucklessRecorder.getVideoCapabilities(camera!!.cameraInfo).getSupportedQualities(DynamicRange.UNSPECIFIED)
|
||||
}
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
binding.takePhotoButton.onPhotoTaken()
|
||||
Toast.makeText(applicationContext, exception.message, Toast.LENGTH_SHORT).show()
|
||||
videoCapture
|
||||
} else {
|
||||
refreshImageCapture()
|
||||
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture)
|
||||
imageCapture
|
||||
})!!
|
||||
adaptPreviewSize(currentUseCase.attachedSurfaceResolution!!.swap())
|
||||
return currentUseCase
|
||||
}
|
||||
|
||||
private fun setupCamera() {
|
||||
if (permissionsGranted && ::extensionsManager.isInitialized && ::cameraProvider.isInitialized) {
|
||||
cameraSelector = if (isBackCamera){ CameraSelector.DEFAULT_BACK_CAMERA } else { CameraSelector.DEFAULT_FRONT_CAMERA }
|
||||
if (extensionsManager.isExtensionAvailable(cameraSelector, ExtensionMode.AUTO)) {
|
||||
cameraSelector = extensionsManager.getExtensionEnabledCameraSelector(cameraSelector, ExtensionMode.AUTO)
|
||||
}
|
||||
})
|
||||
rebindUseCases()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOutputPath(isVideo: Boolean): String {
|
||||
val baseName = if (isVideo) {"VID"} else {"IMG"}+'_'+dateFormat.format(Date())+'_'
|
||||
var fileName: String
|
||||
var outputPath: String
|
||||
do {
|
||||
fileName = baseName+(random.nextInt(fileNameRandomMax-fileNameRandomMin)+fileNameRandomMin)+'.'+ if (isVideo) {"mp4"} else {"jpg"}
|
||||
} while (gocryptfsVolume.pathExists(fileName))
|
||||
return PathUtils.pathJoin(outputDirectory, fileName)
|
||||
val fileName = baseName+(random.nextInt(fileNameRandomMax-fileNameRandomMin)+fileNameRandomMin)+'.'+ if (isVideo) {"mp4"} else {"jpg"}
|
||||
outputPath = PathUtils.pathJoin(outputDirectory, fileName)
|
||||
} while (encryptedVolume.pathExists(outputPath))
|
||||
return outputPath
|
||||
}
|
||||
|
||||
private fun startTimerThen(action: () -> Unit) {
|
||||
if (timerDuration > 0){
|
||||
binding.textTimer.visibility = View.VISIBLE
|
||||
isWaitingForTimer = true
|
||||
lifecycleScope.launch {
|
||||
for (i in timerDuration downTo 1){
|
||||
binding.textTimer.text = i.toString()
|
||||
delay(1000)
|
||||
}
|
||||
if (!isFinishing) {
|
||||
action()
|
||||
binding.textTimer.visibility = View.GONE
|
||||
}
|
||||
isWaitingForTimer = false
|
||||
}
|
||||
} else {
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClickTakePhoto() {
|
||||
val path = getOutputPath(false)
|
||||
if (timerDuration > 0){
|
||||
binding.textTimer.visibility = View.VISIBLE
|
||||
Thread{
|
||||
for (i in timerDuration downTo 1){
|
||||
runOnUiThread { binding.textTimer.text = i.toString() }
|
||||
Thread.sleep(1000)
|
||||
if (!isWaitingForTimer) {
|
||||
val outputPath = getOutputPath(false)
|
||||
startTimerThen {
|
||||
imageCapture?.let { imageCapture ->
|
||||
val outputBuff = ByteArrayOutputStream()
|
||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(outputBuff).build()
|
||||
imageCapture.takePicture(outputOptions, executor, object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||
binding.takePhotoButton.onPhotoTaken()
|
||||
if (encryptedVolume.importFile(ByteArrayInputStream(outputBuff.toByteArray()), outputPath)) {
|
||||
Toast.makeText(applicationContext, getString(R.string.picture_save_success, outputPath), Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
CustomAlertDialogBuilder(this@CameraActivity, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.picture_save_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
binding.takePhotoButton.onPhotoTaken()
|
||||
Toast.makeText(applicationContext, exception.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
})
|
||||
}
|
||||
runOnUiThread {
|
||||
takePhoto(path)
|
||||
binding.textTimer.visibility = View.GONE
|
||||
}
|
||||
}.start()
|
||||
} else {
|
||||
takePhoto(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun onClickRecordVideo() {
|
||||
isRecording = if (isRecording) {
|
||||
videoCapture?.stopRecording()
|
||||
false
|
||||
} else {
|
||||
if (isRecording) {
|
||||
videoRecording?.stop()
|
||||
} else if (!isWaitingForTimer) {
|
||||
val path = getOutputPath(true)
|
||||
val handleId = gocryptfsVolume.openWriteMode(path)
|
||||
videoCapture?.startRecording(VideoCapture.OutputFileOptions(object : SeekableWriter {
|
||||
var offset = 0L
|
||||
override fun write(byteArray: ByteArray) {
|
||||
offset += gocryptfsVolume.writeFile(handleId, offset, byteArray, byteArray.size)
|
||||
val fileHandle = encryptedVolume.openFileWriteMode(path)
|
||||
if (fileHandle == -1L) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.file_creation_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
val writer = AsynchronousSeekableWriter(object : SeekableWriter {
|
||||
private var offset = 0L
|
||||
|
||||
override fun close() {
|
||||
encryptedVolume.closeFile(fileHandle)
|
||||
}
|
||||
|
||||
override fun seek(offset: Long) {
|
||||
this.offset = offset
|
||||
}
|
||||
override fun close() {
|
||||
gocryptfsVolume.closeFile(handleId)
|
||||
}
|
||||
}), executor, object : VideoCapture.OnVideoSavedCallback {
|
||||
override fun onVideoSaved() {
|
||||
Toast.makeText(applicationContext, getString(R.string.video_save_success, path), Toast.LENGTH_SHORT).show()
|
||||
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()
|
||||
cause?.printStackTrace()
|
||||
binding.recordVideoButton.setImageResource(R.drawable.record_video_button)
|
||||
|
||||
override fun write(buffer: ByteArray, size: Int) {
|
||||
offset += encryptedVolume.write(fileHandle, offset, buffer, 0, size.toLong())
|
||||
}
|
||||
})
|
||||
binding.recordVideoButton.setImageResource(R.drawable.stop_recording_video_button)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (!isFinishingIntentionally) {
|
||||
gocryptfsVolume.close()
|
||||
RestrictedFileProvider.wipeAll(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (!isFinishing && !usf_keep_open){
|
||||
finish()
|
||||
val pendingRecording = videoRecorder!!.prepareRecording(
|
||||
this,
|
||||
MuxerOutputOptions(FFmpegMuxer(writer))
|
||||
).also {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
|
||||
it.withAudioEnabled()
|
||||
}
|
||||
}
|
||||
startTimerThen {
|
||||
writer.start()
|
||||
videoRecording = pendingRecording.start(executor) {
|
||||
val buttons = arrayOf(binding.imageCaptureMode, binding.imageRatio, binding.imageTimer, binding.imageModeSwitch, binding.imageCameraSwitch)
|
||||
when (it) {
|
||||
is VideoRecordEvent.Start -> {
|
||||
binding.recordVideoButton.setImageResource(R.drawable.stop_recording_video_button)
|
||||
for (i in buttons) {
|
||||
i.isEnabled = false
|
||||
i.alpha = 0.5F
|
||||
}
|
||||
isRecording = true
|
||||
}
|
||||
is VideoRecordEvent.Finalize -> {
|
||||
if (it.hasError()) {
|
||||
it.cause?.printStackTrace()
|
||||
Toast.makeText(applicationContext, it.cause?.message ?: ("Error: " + it.error), Toast.LENGTH_SHORT).show()
|
||||
videoRecording?.close()
|
||||
videoRecording = null
|
||||
} else {
|
||||
Toast.makeText(applicationContext, getString(R.string.video_save_success, path), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.recordVideoButton.setImageResource(R.drawable.record_video_button)
|
||||
for (i in buttons) {
|
||||
i.isEnabled = true
|
||||
i.alpha = 1F
|
||||
}
|
||||
isRecording = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
sensorOrientationListener.remove(this)
|
||||
if (!isAskingPermissions && !usf_keep_open) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@ -457,27 +588,26 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
sensorOrientationListener.addListener(this)
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
super.onBackPressed()
|
||||
isFinishingIntentionally = true
|
||||
}
|
||||
|
||||
override fun onOrientationChange(newOrientation: Int) {
|
||||
val reversedOrientation = when (newOrientation){
|
||||
90 -> 270
|
||||
270 -> 90
|
||||
else -> newOrientation
|
||||
}.toFloat()
|
||||
val realOrientation = when (newOrientation) {
|
||||
Surface.ROTATION_0 -> 0f
|
||||
Surface.ROTATION_90 -> 90f
|
||||
Surface.ROTATION_180 -> 180f
|
||||
else -> 270f
|
||||
}
|
||||
val rotateAnimation = RotateAnimation(previousOrientation, when {
|
||||
reversedOrientation - previousOrientation > 180 -> reversedOrientation - 360
|
||||
reversedOrientation - previousOrientation < -180 -> reversedOrientation + 360
|
||||
else -> reversedOrientation
|
||||
realOrientation - previousOrientation > 180 -> realOrientation - 360
|
||||
realOrientation - previousOrientation < -180 -> realOrientation + 360
|
||||
else -> realOrientation
|
||||
}, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.5f)
|
||||
rotateAnimation.duration = 300
|
||||
rotateAnimation.interpolator = LinearInterpolator()
|
||||
rotateAnimation.fillAfter = true
|
||||
orientedIcons.map { it.startAnimation(rotateAnimation) }
|
||||
previousOrientation = reversedOrientation
|
||||
previousOrientation = realOrientation
|
||||
imageCapture?.targetRotation = newOrientation
|
||||
videoCapture?.targetRotation = newOrientation
|
||||
currentRotation = newOrientation
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,195 +1,217 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.widget.AdapterView.OnItemClickListener
|
||||
import android.text.InputType
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import sushi.hardcore.droidfs.adapters.SavedVolumesAdapter
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import sushi.hardcore.droidfs.databinding.ActivityChangePasswordBinding
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.util.WidgetUtil
|
||||
import sushi.hardcore.droidfs.util.Wiper
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import java.io.File
|
||||
import sushi.hardcore.droidfs.filesystems.CryfsVolume
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.util.IntentUtils
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.UIUtils
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.util.*
|
||||
|
||||
class ChangePasswordActivity : VolumeActionActivity() {
|
||||
private lateinit var savedVolumesAdapter: SavedVolumesAdapter
|
||||
class ChangePasswordActivity: BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityChangePasswordBinding
|
||||
private lateinit var volume: VolumeData
|
||||
private lateinit var volumeDatabase: VolumeDatabase
|
||||
private var fingerprintProtector: FingerprintProtector? = null
|
||||
private var usfFingerprint: Boolean = false
|
||||
private val inputMethodManager by lazy {
|
||||
getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
volume = IntentUtils.getParcelableExtra(intent, "volume")!!
|
||||
binding = ActivityChangePasswordBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupLayout()
|
||||
setupFingerprintStuff()
|
||||
savedVolumesAdapter = SavedVolumesAdapter(this, volumeDatabase)
|
||||
if (savedVolumesAdapter.count > 0){
|
||||
binding.savedPathListview.adapter = savedVolumesAdapter
|
||||
binding.savedPathListview.onItemClickListener = OnItemClickListener { _, _, position, _ ->
|
||||
val volume = savedVolumesAdapter.getItem(position)
|
||||
currentVolumeName = volume.name
|
||||
if (volume.isHidden){
|
||||
switchHiddenVolume.isChecked = true
|
||||
editVolumeName.setText(currentVolumeName)
|
||||
} else {
|
||||
switchHiddenVolume.isChecked = false
|
||||
editVolumePath.setText(currentVolumeName)
|
||||
}
|
||||
onClickSwitchHiddenVolume()
|
||||
}
|
||||
} else {
|
||||
WidgetUtil.hideWithPadding(binding.savedPathListview)
|
||||
}
|
||||
val textWatcher = object: TextWatcher{
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
if (volumeDatabase.isVolumeSaved(s.toString())){
|
||||
checkboxRememberPath.isEnabled = false
|
||||
checkboxRememberPath.isChecked = true
|
||||
binding.editOldPassword.apply {
|
||||
if (volumeDatabase.isHashSaved(s.toString())){
|
||||
text = null
|
||||
hint = getString(R.string.hash_saved_hint)
|
||||
isEnabled = false
|
||||
} else {
|
||||
hint = null
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
checkboxRememberPath.isEnabled = true
|
||||
binding.editOldPassword.apply {
|
||||
hint = null
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
title = getString(R.string.change_password)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
binding.textVolumeName.text = volume.name
|
||||
volumeDatabase = VolumeDatabase(this)
|
||||
usfFingerprint = sharedPrefs.getBoolean("usf_fingerprint", false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
fingerprintProtector = FingerprintProtector.new(this, theme, volumeDatabase)
|
||||
if (fingerprintProtector != null && volume.encryptedHash != null) {
|
||||
binding.fingerprintSwitchContainer.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
editVolumePath.addTextChangedListener(textWatcher)
|
||||
editVolumeName.addTextChangedListener(textWatcher)
|
||||
binding.editNewPasswordConfirm.setOnEditorActionListener { _, _, _ ->
|
||||
checkVolumePathThenChangePassword()
|
||||
if (!usfFingerprint || fingerprintProtector == null) {
|
||||
binding.checkboxSavePassword.visibility = View.GONE
|
||||
}
|
||||
if (sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false)) {
|
||||
arrayOf(binding.editCurrentPassword, binding.editNewPassword, binding.editPasswordConfirm).forEach {
|
||||
it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
binding.fingerprintSwitchContainer.setOnClickListener {
|
||||
binding.switchUseFingerprint.toggle()
|
||||
}
|
||||
binding.switchUseFingerprint.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (isChecked && binding.editCurrentPassword.hasFocus()) {
|
||||
binding.editCurrentPassword.clearFocus()
|
||||
inputMethodManager.hideSoftInputFromWindow(binding.editCurrentPassword.windowToken, 0)
|
||||
}
|
||||
}
|
||||
binding.editCurrentPassword.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
binding.switchUseFingerprint.isChecked = false
|
||||
}
|
||||
}
|
||||
binding.editPasswordConfirm.setOnEditorActionListener { _, _, _ ->
|
||||
changeVolumePassword()
|
||||
true
|
||||
}
|
||||
binding.buttonChangePassword.setOnClickListener {
|
||||
checkVolumePathThenChangePassword()
|
||||
}
|
||||
binding.button.setOnClickListener { changeVolumePassword() }
|
||||
}
|
||||
|
||||
fun checkVolumePathThenChangePassword() {
|
||||
loadVolumePath {
|
||||
val volumeFile = File(currentVolumePath)
|
||||
if (!GocryptfsVolume.isGocryptfsVolume(volumeFile)){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.error_not_a_volume)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else if (!volumeFile.canWrite()){
|
||||
errorDirectoryNotWritable(R.string.change_pwd_cant_write_error_msg)
|
||||
} else {
|
||||
changePassword()
|
||||
}
|
||||
}
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return if (item.itemId == android.R.id.home) {
|
||||
finish()
|
||||
true
|
||||
} else super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun changePassword(givenHash: ByteArray? = null){
|
||||
val newPassword = binding.editNewPassword.text.toString().toCharArray()
|
||||
val newPasswordConfirm = binding.editNewPasswordConfirm.text.toString().toCharArray()
|
||||
private fun showCurrentPasswordInput() {
|
||||
binding.textCurrentPasswordLabel.visibility = View.VISIBLE
|
||||
binding.editCurrentPassword.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun changeVolumePassword() {
|
||||
val newPassword = UIUtils.encodeEditTextContent(binding.editNewPassword)
|
||||
val newPasswordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
|
||||
@SuppressLint("NewApi")
|
||||
if (!newPassword.contentEquals(newPasswordConfirm)) {
|
||||
Toast.makeText(this, R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
|
||||
Arrays.fill(newPassword, 0)
|
||||
} else {
|
||||
object : LoadingTask(this, R.string.loading_msg_change_password) {
|
||||
override fun doTask(activity: AppCompatActivity) {
|
||||
val oldPassword = binding.editOldPassword.text.toString().toCharArray()
|
||||
var returnedHash: ByteArray? = null
|
||||
if (checkboxSavePassword.isChecked) {
|
||||
returnedHash = ByteArray(GocryptfsVolume.KeyLen)
|
||||
}
|
||||
var changePasswordImmediately = true
|
||||
if (givenHash == null) {
|
||||
var volume: Volume? = null
|
||||
volumeDatabase.getVolumes().forEach { testVolume ->
|
||||
if (testVolume.name == currentVolumeName){
|
||||
volume = testVolume
|
||||
var changeWithCurrentPassword = true
|
||||
volume.encryptedHash?.let { encryptedHash ->
|
||||
volume.iv?.let { iv ->
|
||||
fingerprintProtector?.let {
|
||||
if (binding.switchUseFingerprint.isChecked) {
|
||||
changeWithCurrentPassword = false
|
||||
it.listener = object : FingerprintProtector.Listener {
|
||||
override fun onHashStorageReset() {
|
||||
showCurrentPasswordInput()
|
||||
volume.encryptedHash = null
|
||||
volume.iv = null
|
||||
}
|
||||
override fun onPasswordHashDecrypted(hash: ByteArray) {
|
||||
changeVolumePassword(newPassword, hash)
|
||||
}
|
||||
override fun onPasswordHashSaved() {}
|
||||
override fun onFailed(pending: Boolean) {
|
||||
Arrays.fill(newPassword, 0)
|
||||
}
|
||||
}
|
||||
it.loadPasswordHash(volume.name, encryptedHash, iv)
|
||||
}
|
||||
volume?.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
|
||||
it.hash?.let { hash ->
|
||||
it.iv?.let { iv ->
|
||||
currentVolumePath = if (it.isHidden){
|
||||
PathUtils.pathJoin(filesDir.path, it.name)
|
||||
} else {
|
||||
it.name
|
||||
}
|
||||
stopTask {
|
||||
loadPasswordHash(hash, iv, ::changePassword)
|
||||
}
|
||||
changePasswordImmediately = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changeWithCurrentPassword) {
|
||||
changeVolumePassword(newPassword)
|
||||
}
|
||||
}
|
||||
Arrays.fill(newPasswordConfirm, 0)
|
||||
}
|
||||
|
||||
private fun changeVolumePassword(newPassword: ByteArray, givenHash: ByteArray? = null) {
|
||||
val returnedHash: ObjRef<ByteArray?>? = if (binding.checkboxSavePassword.isChecked) {
|
||||
ObjRef(null)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val currentPassword = if (givenHash == null) {
|
||||
UIUtils.encodeEditTextContent(binding.editCurrentPassword)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
object : LoadingTask<Boolean>(this, theme, R.string.loading_msg_change_password) {
|
||||
override suspend fun doTask(): Boolean {
|
||||
val success = if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) {
|
||||
GocryptfsVolume.changePassword(
|
||||
volume.getFullPath(filesDir.path),
|
||||
currentPassword,
|
||||
givenHash,
|
||||
newPassword,
|
||||
returnedHash?.apply { value = ByteArray(GocryptfsVolume.KeyLen) }?.value
|
||||
)
|
||||
} else {
|
||||
CryfsVolume.changePassword(
|
||||
volume.getFullPath(filesDir.path),
|
||||
filesDir.path,
|
||||
currentPassword,
|
||||
givenHash,
|
||||
newPassword,
|
||||
returnedHash
|
||||
)
|
||||
}
|
||||
if (success) {
|
||||
if (volumeDatabase.isHashSaved(volume)) {
|
||||
volumeDatabase.removeHash(volume)
|
||||
}
|
||||
}
|
||||
if (currentPassword != null)
|
||||
Arrays.fill(currentPassword, 0)
|
||||
Arrays.fill(newPassword, 0)
|
||||
if (givenHash != null)
|
||||
Arrays.fill(givenHash, 0)
|
||||
return success
|
||||
}
|
||||
}.startTask(lifecycleScope) { success ->
|
||||
if (success) {
|
||||
@SuppressLint("NewApi") // if fingerprintProtector is null checkboxSavePassword is hidden
|
||||
if (binding.checkboxSavePassword.isChecked && returnedHash != null) {
|
||||
fingerprintProtector!!.let {
|
||||
it.listener = object : FingerprintProtector.Listener {
|
||||
override fun onHashStorageReset() {
|
||||
// retry
|
||||
it.savePasswordHash(volume, returnedHash.value!!)
|
||||
}
|
||||
override fun onPasswordHashDecrypted(hash: ByteArray) {}
|
||||
override fun onPasswordHashSaved() {
|
||||
Arrays.fill(returnedHash.value!!, 0)
|
||||
finish()
|
||||
}
|
||||
override fun onFailed(pending: Boolean) {
|
||||
if (!pending) {
|
||||
Arrays.fill(returnedHash.value!!, 0)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
it.savePasswordHash(volume, returnedHash.value!!)
|
||||
}
|
||||
if (changePasswordImmediately) {
|
||||
if (GocryptfsVolume.changePassword(currentVolumePath, oldPassword, givenHash, newPassword, returnedHash)) {
|
||||
val volume = Volume(currentVolumeName, switchHiddenVolume.isChecked)
|
||||
if (volumeDatabase.isHashSaved(currentVolumeName)) {
|
||||
volumeDatabase.removeHash(volume)
|
||||
}
|
||||
if (checkboxRememberPath.isChecked) {
|
||||
volumeDatabase.saveVolume(volume)
|
||||
}
|
||||
if (checkboxSavePassword.isChecked && returnedHash != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
|
||||
stopTask {
|
||||
savePasswordHash(returnedHash) {
|
||||
onPasswordChanged()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopTask { onPasswordChanged() }
|
||||
}
|
||||
} else {
|
||||
stopTask {
|
||||
ColoredAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.change_password_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
Arrays.fill(oldPassword, 0.toChar())
|
||||
}
|
||||
override fun doFinally(activity: AppCompatActivity) {
|
||||
Arrays.fill(newPassword, 0.toChar())
|
||||
Arrays.fill(newPasswordConfirm, 0.toChar())
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
} else {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.change_password_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPasswordChanged(){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.success_change_password)
|
||||
.setMessage(R.string.success_change_password_msg)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> finish() }
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Wiper.wipeEditText(binding.editOldPassword)
|
||||
Wiper.wipeEditText(binding.editNewPassword)
|
||||
Wiper.wipeEditText(binding.editNewPasswordConfirm)
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
binding.editCurrentPassword.text.clear()
|
||||
binding.editNewPassword.text.clear()
|
||||
binding.editPasswordConfirm.text.clear()
|
||||
}
|
||||
}
|
20
app/src/main/java/sushi/hardcore/droidfs/ClosingService.kt
Normal file
20
app/src/main/java/sushi/hardcore/droidfs/ClosingService.kt
Normal file
@ -0,0 +1,20 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Dummy background service listening for application task removal in order to
|
||||
* close all volumes still open on quit.
|
||||
*
|
||||
* Should only be running when usfBackground is enabled AND usfKeepOpen is disabled.
|
||||
*/
|
||||
class ClosingService : Service() {
|
||||
override fun onBind(intent: Intent) = null
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
(application as VolumeManagerApp).volumeManager.closeAll()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Application
|
||||
import com.jaredrummler.cyanea.Cyanea
|
||||
|
||||
class ColoredApplication: Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Cyanea.init(this, resources)
|
||||
}
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
|
||||
class ConstValues {
|
||||
companion object {
|
||||
const val creator = "DroidFS"
|
||||
const val gocryptfsConfFilename = "gocryptfs.conf"
|
||||
const val FILE_MODE = 384 //0600
|
||||
const val DIRECTORY_MODE = 448 //0700
|
||||
const val volumeDatabaseName = "SavedVolumes"
|
||||
const val sort_order_key = "sort_order"
|
||||
val fakeUri: Uri = Uri.parse("fakeuri://droidfs")
|
||||
const val MAX_KERNEL_WRITE = 128*1024
|
||||
const val wipe_passes = 2
|
||||
const val slideshow_delay: Long = 4000
|
||||
private val fileExtensions = mapOf(
|
||||
Pair("image", listOf("png", "jpg", "jpeg", "gif", "bmp")),
|
||||
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
|
||||
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac")),
|
||||
Pair("text", listOf("txt", "json", "conf", "log", "xml", "java", "kt", "py", "pl", "rb", "go", "c", "h", "cpp", "hpp", "rs", "sh", "bat", "js", "html", "css", "php", "yml", "yaml", "toml", "ini", "md", "properties"))
|
||||
)
|
||||
|
||||
fun isExtensionType(extensionType: String, path: String): Boolean {
|
||||
return fileExtensions[extensionType]?.contains(File(path).extension.lowercase()) ?: false
|
||||
}
|
||||
|
||||
fun isImage(path: String): Boolean {
|
||||
return isExtensionType("image", path)
|
||||
}
|
||||
fun isVideo(path: String): Boolean {
|
||||
return isExtensionType("video", path)
|
||||
}
|
||||
fun isAudio(path: String): Boolean {
|
||||
return isExtensionType("audio", path)
|
||||
}
|
||||
fun isText(path: String): Boolean {
|
||||
return isExtensionType("text", path)
|
||||
}
|
||||
fun getAssociatedDrawable(path: String): Int {
|
||||
return when {
|
||||
isAudio(path) -> R.drawable.icon_file_audio
|
||||
isImage(path) -> R.drawable.icon_file_image
|
||||
isVideo(path) -> R.drawable.icon_file_video
|
||||
isText(path) -> R.drawable.icon_file_text
|
||||
else -> R.drawable.icon_file_unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
19
app/src/main/java/sushi/hardcore/droidfs/Constants.kt
Normal file
19
app/src/main/java/sushi/hardcore/droidfs/Constants.kt
Normal 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"
|
||||
}
|
@ -1,180 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.*
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import sushi.hardcore.droidfs.databinding.ActivityCreateBinding
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerActivity
|
||||
import sushi.hardcore.droidfs.util.Wiper
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
class CreateActivity : VolumeActionActivity() {
|
||||
private var sessionID = -1
|
||||
private var isStartingExplorer = false
|
||||
private lateinit var binding: ActivityCreateBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityCreateBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupLayout()
|
||||
setupFingerprintStuff(mayDecrypt = false)
|
||||
binding.editPasswordConfirm.setOnEditorActionListener { _, _, _ ->
|
||||
createVolume()
|
||||
true
|
||||
}
|
||||
binding.spinnerXchacha.adapter = ArrayAdapter(
|
||||
this@CreateActivity,
|
||||
android.R.layout.simple_spinner_item,
|
||||
resources.getStringArray(R.array.encryption_cipher)
|
||||
).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
binding.spinnerXchacha.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
if (position == 1) {
|
||||
ColoredAlertDialogBuilder(this@CreateActivity)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.xchacha_warning)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
binding.buttonCreate.setOnClickListener {
|
||||
createVolume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClickSwitchHiddenVolume() {
|
||||
super.onClickSwitchHiddenVolume()
|
||||
if (switchHiddenVolume.isChecked){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.hidden_volume_warning)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun createVolume() {
|
||||
loadVolumePath {
|
||||
val password = binding.editPassword.text.toString().toCharArray()
|
||||
val passwordConfirm = binding.editPasswordConfirm.text.toString().toCharArray()
|
||||
if (!password.contentEquals(passwordConfirm)) {
|
||||
Toast.makeText(this, R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
val volumeFile = File(currentVolumePath)
|
||||
var goodDirectory = false
|
||||
if (!volumeFile.isDirectory) {
|
||||
if (volumeFile.mkdirs()) {
|
||||
goodDirectory = true
|
||||
} else {
|
||||
errorDirectoryNotWritable(R.string.create_cant_write_error_msg)
|
||||
}
|
||||
} else {
|
||||
val dirContent = volumeFile.list()
|
||||
if (dirContent != null) {
|
||||
if (dirContent.isEmpty()) {
|
||||
if (volumeFile.canWrite()) {
|
||||
goodDirectory = true
|
||||
} else {
|
||||
errorDirectoryNotWritable(R.string.create_cant_write_error_msg)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(this, R.string.dir_not_empty, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(this, R.string.listdir_null_error_msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
if (goodDirectory) {
|
||||
object: LoadingTask(this, R.string.loading_msg_create){
|
||||
override fun doTask(activity: AppCompatActivity) {
|
||||
val xchacha = when (binding.spinnerXchacha.selectedItemPosition) {
|
||||
0 -> 0
|
||||
1 -> 1
|
||||
else -> -1
|
||||
}
|
||||
if (GocryptfsVolume.createVolume(currentVolumePath, password, false, xchacha, GocryptfsVolume.ScryptDefaultLogN, ConstValues.creator)) {
|
||||
var returnedHash: ByteArray? = null
|
||||
if (checkboxSavePassword.isChecked){
|
||||
returnedHash = ByteArray(GocryptfsVolume.KeyLen)
|
||||
}
|
||||
sessionID = GocryptfsVolume.init(currentVolumePath, password, null, returnedHash)
|
||||
if (sessionID != -1) {
|
||||
if (checkboxRememberPath.isChecked) {
|
||||
if (volumeDatabase.isVolumeSaved(currentVolumeName)) { //cleaning old saved path
|
||||
volumeDatabase.removeVolume(Volume(currentVolumeName))
|
||||
}
|
||||
volumeDatabase.saveVolume(Volume(currentVolumeName, switchHiddenVolume.isChecked))
|
||||
}
|
||||
if (checkboxSavePassword.isChecked && returnedHash != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
|
||||
stopTask {
|
||||
savePasswordHash(returnedHash) {
|
||||
startExplorer()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopTask { startExplorer() }
|
||||
}
|
||||
} else {
|
||||
stopTaskWithToast(R.string.open_volume_failed)
|
||||
}
|
||||
} else {
|
||||
stopTask {
|
||||
ColoredAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.create_volume_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun doFinally(activity: AppCompatActivity) {
|
||||
Arrays.fill(password, 0.toChar())
|
||||
Arrays.fill(passwordConfirm, 0.toChar())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startExplorer(){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.success_volume_create)
|
||||
.setMessage(R.string.success_volume_create_msg)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
val intent = Intent(this, ExplorerActivity::class.java)
|
||||
intent.putExtra("sessionID", sessionID)
|
||||
intent.putExtra("volume_name", File(currentVolumeName).name)
|
||||
startActivity(intent)
|
||||
isStartingExplorer = true
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
//Closing volume if leaving activity while showing dialog
|
||||
if (sessionID != -1 && !isStartingExplorer) {
|
||||
GocryptfsVolume(sessionID).close()
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Wiper.wipeEditText(binding.editPassword)
|
||||
Wiper.wipeEditText(binding.editPasswordConfirm)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
78
app/src/main/java/sushi/hardcore/droidfs/FileShare.kt
Normal file
78
app/src/main/java/sushi/hardcore/droidfs/FileShare.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
87
app/src/main/java/sushi/hardcore/droidfs/FileTypes.kt
Normal file
87
app/src/main/java/sushi/hardcore/droidfs/FileTypes.kt
Normal 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)
|
||||
}
|
||||
}
|
259
app/src/main/java/sushi/hardcore/droidfs/FingerprintProtector.kt
Normal file
259
app/src/main/java/sushi/hardcore/droidfs/FingerprintProtector.kt
Normal file
@ -0,0 +1,259 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.security.KeyStore
|
||||
import java.security.KeyStoreException
|
||||
import java.security.UnrecoverableKeyException
|
||||
import javax.crypto.*
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
class FingerprintProtector private constructor(
|
||||
private val activity: FragmentActivity,
|
||||
private val theme: Theme,
|
||||
private val volumeDatabase: VolumeDatabase,
|
||||
) {
|
||||
|
||||
interface Listener {
|
||||
fun onHashStorageReset()
|
||||
fun onPasswordHashDecrypted(hash: ByteArray)
|
||||
fun onPasswordHashSaved()
|
||||
fun onFailed(pending: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||
private const val KEY_ALIAS = "Hash Key"
|
||||
private const val KEY_SIZE = 256
|
||||
private const val GCM_TAG_LEN = 128
|
||||
|
||||
fun canAuthenticate(context: Context): Int {
|
||||
val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
return if (!keyguardManager.isKeyguardSecure)
|
||||
1
|
||||
else when (BiometricManager.from(context).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> 0
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> 2
|
||||
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> 3
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> 4
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
fun new(
|
||||
activity: FragmentActivity,
|
||||
theme: Theme,
|
||||
volumeDatabase: VolumeDatabase,
|
||||
): FingerprintProtector? {
|
||||
return if (canAuthenticate(activity) == 0)
|
||||
FingerprintProtector(activity, theme, volumeDatabase)
|
||||
else
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
lateinit var listener: Listener
|
||||
private val biometricPrompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity), object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
if (
|
||||
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
|
||||
errorCode != BiometricPrompt.ERROR_TIMEOUT
|
||||
) {
|
||||
Toast.makeText(activity, activity.getString(R.string.biometric_error, errString), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
listener.onFailed(false)
|
||||
}
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
val cipherObject = result.cryptoObject?.cipher
|
||||
if (cipherObject != null) {
|
||||
try {
|
||||
when (cipherActionMode) {
|
||||
Cipher.ENCRYPT_MODE -> {
|
||||
val cipherText = cipherObject.doFinal(dataToProcess)
|
||||
volume.encryptedHash = cipherText
|
||||
volume.iv = cipherObject.iv
|
||||
if (volumeDatabase.addHash(volume))
|
||||
listener.onPasswordHashSaved()
|
||||
else
|
||||
listener.onFailed(false)
|
||||
}
|
||||
Cipher.DECRYPT_MODE -> {
|
||||
try {
|
||||
val plainText = cipherObject.doFinal(dataToProcess)
|
||||
listener.onPasswordHashDecrypted(plainText)
|
||||
} catch (e: AEADBadTagException) {
|
||||
listener.onFailed(true)
|
||||
CustomAlertDialogBuilder(activity, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.MAC_verification_failed)
|
||||
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
|
||||
resetHashStorage()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> listener.onFailed(false) }
|
||||
.setOnCancelListener { listener.onFailed(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalBlockSizeException) {
|
||||
listener.onFailed(true)
|
||||
CustomAlertDialogBuilder(activity, theme)
|
||||
.setTitle(R.string.illegal_block_size_exception)
|
||||
.setMessage(R.string.illegal_block_size_exception_msg)
|
||||
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
|
||||
resetHashStorage()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> listener.onFailed(false) }
|
||||
.setOnCancelListener { listener.onFailed(false) }
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(activity, R.string.error_cipher_null, Toast.LENGTH_SHORT).show()
|
||||
listener.onFailed(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
private lateinit var keyStore: KeyStore
|
||||
private lateinit var key: SecretKey
|
||||
private lateinit var cipher: Cipher
|
||||
private var isCipherReady = false
|
||||
private var cipherActionMode: Int? = null
|
||||
private lateinit var volume: VolumeData
|
||||
private lateinit var dataToProcess: ByteArray
|
||||
|
||||
private fun resetHashStorage() {
|
||||
try {
|
||||
keyStore.deleteEntry(KEY_ALIAS)
|
||||
} catch (e: KeyStoreException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
volumeDatabase.getVolumes().forEach { volume ->
|
||||
volumeDatabase.removeHash(volume)
|
||||
}
|
||||
isCipherReady = false
|
||||
Toast.makeText(activity, R.string.hash_storage_reset, Toast.LENGTH_SHORT).show()
|
||||
listener.onHashStorageReset()
|
||||
}
|
||||
|
||||
private fun prepareCipher(): Boolean {
|
||||
if (!isCipherReady) {
|
||||
keyStore = KeyStore.getInstance(ANDROID_KEY_STORE)
|
||||
keyStore.load(null)
|
||||
key = if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||
try {
|
||||
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||
} catch (e: UnrecoverableKeyException) {
|
||||
listener.onFailed(true)
|
||||
CustomAlertDialogBuilder(activity, theme)
|
||||
.setTitle(activity.getString(R.string.unrecoverable_key_exception))
|
||||
.setMessage(activity.getString(R.string.unrecoverable_key_exception_msg, e.localizedMessage))
|
||||
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
|
||||
resetHashStorage()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> listener.onFailed(false) }
|
||||
.setOnCancelListener { listener.onFailed(false) }
|
||||
.show()
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
val builder = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
builder.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
builder.setKeySize(KEY_SIZE)
|
||||
builder.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
builder.setUserAuthenticationRequired(true)
|
||||
val keyGenerator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
ANDROID_KEY_STORE
|
||||
)
|
||||
keyGenerator.init(builder.build())
|
||||
keyGenerator.generateKey()
|
||||
}
|
||||
cipher = Cipher.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES + "/" + KeyProperties.BLOCK_MODE_GCM + "/" + KeyProperties.ENCRYPTION_PADDING_NONE
|
||||
)
|
||||
isCipherReady = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun alertKeyPermanentlyInvalidatedException() {
|
||||
listener.onFailed(true)
|
||||
CustomAlertDialogBuilder(activity, theme)
|
||||
.setTitle(R.string.key_permanently_invalidated_exception)
|
||||
.setMessage(R.string.key_permanently_invalidated_exception_msg)
|
||||
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
|
||||
resetHashStorage()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> listener.onFailed(false) }
|
||||
.setOnCancelListener { listener.onFailed(false) }
|
||||
.show()
|
||||
}
|
||||
|
||||
fun savePasswordHash(volume: VolumeData, plainText: ByteArray) {
|
||||
this.volume = volume
|
||||
val biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(activity.getString(R.string.encrypt_action_description))
|
||||
.setSubtitle(volume.shortName)
|
||||
.setDescription(activity.getString(R.string.fingerprint_instruction))
|
||||
.setNegativeButtonText(activity.getString(R.string.cancel))
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
cipherActionMode = Cipher.ENCRYPT_MODE
|
||||
if (prepareCipher()) {
|
||||
try {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
dataToProcess = plainText
|
||||
biometricPrompt.authenticate(
|
||||
biometricPromptInfo,
|
||||
BiometricPrompt.CryptoObject(cipher)
|
||||
)
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
alertKeyPermanentlyInvalidatedException()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPasswordHash(volumeName: String, cipherText: ByteArray, iv: ByteArray) {
|
||||
val biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(activity.getString(R.string.decrypt_action_description))
|
||||
.setSubtitle(volumeName)
|
||||
.setDescription(activity.getString(R.string.fingerprint_instruction))
|
||||
.setNegativeButtonText(activity.getString(R.string.cancel))
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
cipherActionMode = Cipher.DECRYPT_MODE
|
||||
if (prepareCipher()) {
|
||||
dataToProcess = cipherText
|
||||
val gcmSpec = GCMParameterSpec(GCM_TAG_LEN, iv)
|
||||
try {
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec)
|
||||
biometricPrompt.authenticate(
|
||||
biometricPromptInfo,
|
||||
BiometricPrompt.CryptoObject(cipher)
|
||||
)
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
alertKeyPermanentlyInvalidatedException()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,240 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
class GocryptfsVolume(var sessionID: Int) {
|
||||
private external fun native_close(sessionID: Int)
|
||||
private external fun native_is_closed(sessionID: Int): Boolean
|
||||
private external fun native_list_dir(sessionID: Int, dir_path: String): MutableList<ExplorerElement>
|
||||
private external fun native_open_read_mode(sessionID: Int, file_path: String): Int
|
||||
private external fun native_open_write_mode(sessionID: Int, file_path: String, mode: Int): Int
|
||||
private external fun native_read_file(sessionID: Int, handleID: Int, offset: Long, buff: ByteArray): Int
|
||||
private external fun native_write_file(sessionID: Int, handleID: Int, offset: Long, buff: ByteArray, buff_size: Int): Int
|
||||
private external fun native_truncate(sessionID: Int, handleID: Int, offset: Long): Boolean
|
||||
private external fun native_path_exists(sessionID: Int, file_path: String): Boolean
|
||||
private external fun native_get_size(sessionID: Int, file_path: String): Long
|
||||
private external fun native_close_file(sessionID: Int, handleID: Int)
|
||||
private external fun native_remove_file(sessionID: Int, file_path: String): Boolean
|
||||
private external fun native_mkdir(sessionID: Int, dir_path: String, mode: Int): Boolean
|
||||
private external fun native_rmdir(sessionID: Int, dir_path: String): Boolean
|
||||
private external fun native_rename(sessionID: Int, old_path: String, new_path: String): Boolean
|
||||
|
||||
companion object {
|
||||
const val KeyLen = 32
|
||||
const val ScryptDefaultLogN = 16
|
||||
const val DefaultBS = 4096
|
||||
external fun createVolume(root_cipher_dir: String, password: CharArray, plainTextNames: Boolean, xchacha: Int, logN: Int, creator: String): Boolean
|
||||
external fun init(root_cipher_dir: String, password: CharArray?, givenHash: ByteArray?, returnedHash: ByteArray?): Int
|
||||
external fun changePassword(root_cipher_dir: String, old_password: CharArray?, givenHash: ByteArray?, new_password: CharArray, returnedHash: ByteArray?): Boolean
|
||||
|
||||
fun isGocryptfsVolume(path: File): Boolean {
|
||||
if (path.isDirectory){
|
||||
return File(path, ConstValues.gocryptfsConfFilename).isFile
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
init {
|
||||
System.loadLibrary("gocryptfs_jni")
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
synchronized(this){
|
||||
native_close(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
fun isClosed(): Boolean {
|
||||
synchronized(this){
|
||||
return native_is_closed(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
fun listDir(dir_path: String): MutableList<ExplorerElement> {
|
||||
synchronized(this){
|
||||
return native_list_dir(sessionID, dir_path)
|
||||
}
|
||||
}
|
||||
|
||||
fun mkdir(dir_path: String): Boolean {
|
||||
synchronized(this){
|
||||
return native_mkdir(sessionID, dir_path, ConstValues.DIRECTORY_MODE)
|
||||
}
|
||||
}
|
||||
|
||||
fun rmdir(dir_path: String): Boolean {
|
||||
synchronized(this){
|
||||
return native_rmdir(sessionID, dir_path)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFile(file_path: String): Boolean {
|
||||
synchronized(this){
|
||||
return native_remove_file(sessionID, file_path)
|
||||
}
|
||||
}
|
||||
|
||||
fun pathExists(file_path: String): Boolean {
|
||||
synchronized(this){
|
||||
return native_path_exists(sessionID, file_path)
|
||||
}
|
||||
}
|
||||
|
||||
fun getSize(file_path: String): Long {
|
||||
synchronized(this){
|
||||
return native_get_size(sessionID, file_path)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeFile(handleID: Int) {
|
||||
synchronized(this){
|
||||
native_close_file(sessionID, handleID)
|
||||
}
|
||||
}
|
||||
|
||||
fun openReadMode(file_path: String): Int {
|
||||
synchronized(this){
|
||||
return native_open_read_mode(sessionID, file_path)
|
||||
}
|
||||
}
|
||||
|
||||
fun openWriteMode(file_path: String): Int {
|
||||
synchronized(this){
|
||||
return native_open_write_mode(sessionID, file_path, ConstValues.FILE_MODE)
|
||||
}
|
||||
}
|
||||
|
||||
fun readFile(handleID: Int, offset: Long, buff: ByteArray): Int {
|
||||
synchronized(this){
|
||||
return native_read_file(sessionID, handleID, offset, buff)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFile(handleID: Int, offset: Long, buff: ByteArray, buff_size: Int): Int {
|
||||
synchronized(this){
|
||||
return native_write_file(sessionID, handleID, offset, buff, buff_size)
|
||||
}
|
||||
}
|
||||
|
||||
fun truncate(handleID: Int, offset: Long): Boolean {
|
||||
synchronized(this) {
|
||||
return native_truncate(sessionID, handleID, offset)
|
||||
}
|
||||
}
|
||||
|
||||
fun rename(old_path: String, new_path: String): Boolean {
|
||||
synchronized(this) {
|
||||
return native_rename(sessionID, old_path, new_path)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportFile(handleID: Int, os: OutputStream): Boolean {
|
||||
var offset: Long = 0
|
||||
val ioBuffer = ByteArray(DefaultBS)
|
||||
var length: Int
|
||||
while (readFile(handleID, offset, ioBuffer).also { length = it } > 0){
|
||||
os.write(ioBuffer, 0, length)
|
||||
offset += length.toLong()
|
||||
}
|
||||
os.close()
|
||||
return true
|
||||
}
|
||||
|
||||
fun exportFile(src_path: String, os: OutputStream): Boolean {
|
||||
var success = false
|
||||
val srcHandleId = openReadMode(src_path)
|
||||
if (srcHandleId != -1) {
|
||||
success = exportFile(srcHandleId, os)
|
||||
closeFile(srcHandleId)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
fun exportFile(src_path: String, dst_path: String): Boolean {
|
||||
return exportFile(src_path, FileOutputStream(dst_path))
|
||||
}
|
||||
|
||||
fun exportFile(context: Context, src_path: String, output_path: Uri): Boolean {
|
||||
val os = context.contentResolver.openOutputStream(output_path)
|
||||
if (os != null){
|
||||
return exportFile(src_path, os)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun importFile(inputStream: InputStream, handleID: Int): Boolean {
|
||||
var offset: Long = 0
|
||||
val ioBuffer = ByteArray(DefaultBS)
|
||||
var length: Int
|
||||
while (inputStream.read(ioBuffer).also { length = it } > 0) {
|
||||
val written = writeFile(handleID, offset, ioBuffer, length).toLong()
|
||||
if (written == length.toLong()) {
|
||||
offset += written
|
||||
} else {
|
||||
inputStream.close()
|
||||
return false
|
||||
}
|
||||
}
|
||||
closeFile(handleID)
|
||||
inputStream.close()
|
||||
return true
|
||||
}
|
||||
|
||||
fun importFile(inputStream: InputStream, dst_path: String): Boolean {
|
||||
var success = false
|
||||
val dstHandleId = openWriteMode(dst_path)
|
||||
if (dstHandleId != -1) {
|
||||
success = importFile(inputStream, dstHandleId)
|
||||
closeFile(dstHandleId)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
fun importFile(context: Context, src_uri: Uri, dst_path: String): Boolean {
|
||||
val inputStream = context.contentResolver.openInputStream(src_uri)
|
||||
if (inputStream != null){
|
||||
return importFile(inputStream, dst_path)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun recursiveMapFiles(rootPath: String): MutableList<ExplorerElement> {
|
||||
val result = mutableListOf<ExplorerElement>()
|
||||
val explorerElements = listDir(rootPath)
|
||||
result.addAll(explorerElements)
|
||||
for (e in explorerElements){
|
||||
if (e.isDirectory){
|
||||
result.addAll(recursiveMapFiles(e.fullPath))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun recursiveRemoveDirectory(plain_directory_path: String): String? {
|
||||
val explorerElements = listDir(plain_directory_path)
|
||||
for (e in explorerElements) {
|
||||
val fullPath = PathUtils.pathJoin(plain_directory_path, e.name)
|
||||
if (e.isDirectory) {
|
||||
val result = recursiveRemoveDirectory(fullPath)
|
||||
result?.let { return it }
|
||||
} else {
|
||||
if (!removeFile(fullPath)) {
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (!rmdir(plain_directory_path)) {
|
||||
plain_directory_path
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
120
app/src/main/java/sushi/hardcore/droidfs/KeepAliveService.kt
Normal file
120
app/src/main/java/sushi/hardcore/droidfs/KeepAliveService.kt
Normal file
@ -0,0 +1,120 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
|
||||
class KeepAliveService: Service() {
|
||||
internal class NotificationDetails(
|
||||
val channel: String,
|
||||
val title: String,
|
||||
val text: String,
|
||||
val action: NotificationAction,
|
||||
) : Parcelable {
|
||||
internal class NotificationAction(
|
||||
val icon: Int,
|
||||
val title: String,
|
||||
val action: String,
|
||||
)
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
NotificationAction(
|
||||
parcel.readInt(),
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
)
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
with (parcel) {
|
||||
writeString(channel)
|
||||
writeString(title)
|
||||
writeString(text)
|
||||
writeInt(action.icon)
|
||||
writeString(action.title)
|
||||
writeString(action.action)
|
||||
}
|
||||
}
|
||||
|
||||
override fun describeContents() = 0
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<NotificationDetails> {
|
||||
override fun createFromParcel(parcel: Parcel) = NotificationDetails(parcel)
|
||||
override fun newArray(size: Int) = arrayOfNulls<NotificationDetails>(size)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "start"
|
||||
|
||||
/**
|
||||
* If [startForeground] is called before notification permission is granted,
|
||||
* the notification won't appear.
|
||||
*
|
||||
* This action can be used once the permission is granted, to make the service
|
||||
* call [startForeground] again in order to properly show the notification.
|
||||
*/
|
||||
const val ACTION_FOREGROUND = "foreground"
|
||||
const val NOTIFICATION_CHANNEL_ID = "KeepAlive"
|
||||
}
|
||||
|
||||
private val notificationManager by lazy {
|
||||
NotificationManagerCompat.from(this)
|
||||
}
|
||||
private var notification: Notification? = null
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
if (intent.action == ACTION_START) {
|
||||
val notificationDetails = IntentCompat.getParcelableExtra(intent, "notification", NotificationDetails::class.java)!!
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
notificationDetails.channel,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
)
|
||||
}
|
||||
notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(notificationDetails.title)
|
||||
.setContentText(notificationDetails.text)
|
||||
.addAction(NotificationCompat.Action(
|
||||
notificationDetails.action.icon,
|
||||
notificationDetails.action.title,
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||
action = notificationDetails.action.action
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
))
|
||||
.build()
|
||||
}
|
||||
ServiceCompat.startForeground(this, startId, notification!!, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC // is there a better use case flag?
|
||||
} else {
|
||||
0
|
||||
})
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
@ -1,45 +1,34 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sushi.hardcore.droidfs.databinding.DialogLoadingBinding
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
|
||||
abstract class LoadingTask(val activity: AppCompatActivity, loadingMessageResId: Int) {
|
||||
private val dialogLoadingView = activity.layoutInflater.inflate(R.layout.dialog_loading, null)
|
||||
private val dialogLoading: AlertDialog = ColoredAlertDialogBuilder(activity)
|
||||
.setView(dialogLoadingView)
|
||||
abstract class LoadingTask<T>(val activity: FragmentActivity, theme: Theme, loadingMessageResId: Int) {
|
||||
private val dialogLoading = CustomAlertDialogBuilder(activity, theme)
|
||||
.setView(
|
||||
DialogLoadingBinding.inflate(activity.layoutInflater).apply {
|
||||
textMessage.text = activity.getString(loadingMessageResId)
|
||||
}.root
|
||||
)
|
||||
.setTitle(R.string.loading)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
private var isStopped = false
|
||||
init {
|
||||
dialogLoadingView.findViewById<TextView>(R.id.text_message).text = activity.getString(loadingMessageResId)
|
||||
startTask()
|
||||
}
|
||||
abstract fun doTask(activity: AppCompatActivity)
|
||||
open fun doFinally(activity: AppCompatActivity){}
|
||||
private fun startTask() {
|
||||
|
||||
abstract suspend fun doTask(): T
|
||||
|
||||
fun startTask(scope: CoroutineScope, onDone: (T) -> Unit) {
|
||||
dialogLoading.show()
|
||||
Thread {
|
||||
doTask(activity)
|
||||
if (!isStopped){
|
||||
dialogLoading.dismiss()
|
||||
}
|
||||
activity.runOnUiThread { doFinally(activity) }
|
||||
}.start()
|
||||
}
|
||||
fun stopTask(onUiThread: (() -> Unit)?){
|
||||
isStopped = true
|
||||
dialogLoading.dismiss()
|
||||
onUiThread?.let {
|
||||
activity.runOnUiThread {
|
||||
onUiThread()
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
doTask()
|
||||
}
|
||||
dialogLoading.dismiss()
|
||||
onDone(result)
|
||||
}
|
||||
}
|
||||
protected fun stopTaskWithToast(stringId: Int){
|
||||
stopTask { Toast.makeText(activity, stringId, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
88
app/src/main/java/sushi/hardcore/droidfs/LogcatActivity.kt
Normal file
88
app/src/main/java/sushi/hardcore/droidfs/LogcatActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,49 +1,339 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
|
||||
import sushi.hardcore.droidfs.adapters.VolumeAdapter
|
||||
import sushi.hardcore.droidfs.add_volume.AddVolumeActivity
|
||||
import sushi.hardcore.droidfs.content_providers.VolumeProvider
|
||||
import sushi.hardcore.droidfs.databinding.ActivityMainBinding
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerRouter
|
||||
import sushi.hardcore.droidfs.file_operations.FileOperationService
|
||||
import sushi.hardcore.droidfs.file_operations.TaskResult
|
||||
import sushi.hardcore.droidfs.util.IntentUtils
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.util.UIUtils
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : BaseActivity() {
|
||||
class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
companion object {
|
||||
private const val OPEN_DEFAULT_VOLUME = "openDefault"
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var volumeDatabase: VolumeDatabase
|
||||
private lateinit var volumeManager: VolumeManager
|
||||
private lateinit var volumeAdapter: VolumeAdapter
|
||||
private lateinit var volumeOpener: VolumeOpener
|
||||
private var addVolume = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if ((explorerRouter.pickMode || explorerRouter.dropMode) && result.resultCode != AddVolumeActivity.RESULT_USER_BACK) {
|
||||
setResult(result.resultCode, result.data) // forward result
|
||||
finish()
|
||||
}
|
||||
}
|
||||
private var selectedVolumePosition: Int? = null
|
||||
private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
selectedVolumePosition?.let { unselect(it) }
|
||||
}
|
||||
private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null)
|
||||
onDirectoryPicked(uri)
|
||||
}
|
||||
private lateinit var fileOperationService: FileOperationService
|
||||
private lateinit var explorerRouter: ExplorerRouter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.toolbar.toolbar)
|
||||
if (!isRecreating) {
|
||||
if (sharedPrefs.getBoolean("applicationFirstOpening", true)){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.usf_home_warning_msg)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.see_unsafe_features){ _, _ ->
|
||||
val intent = Intent(this, SettingsActivity::class.java)
|
||||
intent.putExtra("screen", "UnsafeFeaturesSettingsFragment")
|
||||
startActivity(intent)
|
||||
if (sharedPrefs.getBoolean("applicationFirstOpening", true)) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.usf_home_warning_msg)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.see_unsafe_features) { _, _ ->
|
||||
val intent = Intent(this, SettingsActivity::class.java)
|
||||
intent.putExtra("screen", "UnsafeFeaturesSettingsFragment")
|
||||
startActivity(intent)
|
||||
}
|
||||
.setNegativeButton(R.string.ok, null)
|
||||
.setOnDismissListener {
|
||||
with (sharedPrefs.edit()) {
|
||||
putBoolean("applicationFirstOpening", false)
|
||||
apply()
|
||||
}
|
||||
.setNegativeButton(R.string.ok, null)
|
||||
.setOnDismissListener { sharedPrefs.edit().putBoolean("applicationFirstOpening", false).apply() }
|
||||
.show()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
explorerRouter = ExplorerRouter(this, intent)
|
||||
volumeManager = (application as VolumeManagerApp).volumeManager
|
||||
volumeDatabase = VolumeDatabase(this)
|
||||
volumeAdapter = VolumeAdapter(
|
||||
this,
|
||||
volumeDatabase,
|
||||
(application as VolumeManagerApp).volumeManager,
|
||||
!explorerRouter.pickMode && !explorerRouter.dropMode,
|
||||
!explorerRouter.dropMode,
|
||||
this,
|
||||
)
|
||||
binding.recyclerViewVolumes.adapter = volumeAdapter
|
||||
binding.recyclerViewVolumes.layoutManager = LinearLayoutManager(this)
|
||||
if (volumeAdapter.volumes.isEmpty()) {
|
||||
binding.textNoVolumes.visibility = View.VISIBLE
|
||||
}
|
||||
if (explorerRouter.pickMode) {
|
||||
title = getString(R.string.select_volume)
|
||||
}
|
||||
binding.fab.setOnClickListener {
|
||||
addVolume.launch(Intent(this, AddVolumeActivity::class.java).also {
|
||||
if (explorerRouter.dropMode || explorerRouter.pickMode) {
|
||||
IntentUtils.forwardIntent(intent, it)
|
||||
}
|
||||
})
|
||||
}
|
||||
volumeOpener = VolumeOpener(this)
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
if (volumeAdapter.selectedItems.isNotEmpty()) {
|
||||
unselectAll()
|
||||
} else {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
binding.buttonOpen.setOnClickListener {
|
||||
startActivity(OpenActivity::class.java)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.buttonCreate.setOnClickListener {
|
||||
startActivity(CreateActivity::class.java)
|
||||
FileOperationService.bind(this) {
|
||||
fileOperationService = it
|
||||
}
|
||||
binding.buttonChangePassword.setOnClickListener {
|
||||
startActivity(ChangePasswordActivity::class.java)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
// check if theme was changed (by SettingsActivity)
|
||||
val newTheme = Theme.fromSharedPrefs(sharedPrefs)
|
||||
if (newTheme != theme) {
|
||||
recreate()
|
||||
} else {
|
||||
volumeAdapter.refresh()
|
||||
invalidateOptionsMenu()
|
||||
if (volumeAdapter.volumes.isNotEmpty()) {
|
||||
binding.textNoVolumes.visibility = View.GONE
|
||||
}
|
||||
// refresh this in case another instance of MainActivity changes its value
|
||||
volumeOpener.defaultVolumeName = sharedPrefs.getString(DEFAULT_VOLUME_KEY, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putBoolean(OPEN_DEFAULT_VOLUME, false)
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(size: Int) {
|
||||
title = if (size == 0) {
|
||||
getString(R.string.app_name)
|
||||
} else {
|
||||
getString(R.string.elements_selected, size, volumeAdapter.volumes.size)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onVolumeItemClick(volume: VolumeData, position: Int) {
|
||||
if (volumeAdapter.selectedItems.isEmpty())
|
||||
openVolume(volume)
|
||||
else
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
override fun onVolumeItemLongClick() {
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
private fun unselectAll(notifyChange: Boolean = true) {
|
||||
volumeAdapter.unSelectAll(notifyChange)
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
private fun unselect(position: Int) {
|
||||
volumeAdapter.unselect(position)
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
private fun removeVolume(volume: VolumeData) {
|
||||
volumeManager.getVolumeId(volume)?.let { volumeManager.closeVolume(it) }
|
||||
volumeDatabase.removeVolume(volume)
|
||||
}
|
||||
|
||||
private fun removeVolumes(volumes: List<VolumeData>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
|
||||
if (i < volumes.size) {
|
||||
if (volumes[i].isHidden) {
|
||||
if (doDeleteVolumeContent == null) {
|
||||
val dialogBinding = DialogDeleteVolumeBinding.inflate(layoutInflater)
|
||||
dialogBinding.textContent.text = getString(R.string.delete_hidden_volume_question, volumes[i].name)
|
||||
// show checkbox only if there is at least one other hidden volume
|
||||
for (j in (i+1 until volumes.size)) {
|
||||
if (volumes[j].isHidden) {
|
||||
dialogBinding.checkboxApplyToAll.visibility = View.VISIBLE
|
||||
break
|
||||
}
|
||||
}
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setView(dialogBinding.root)
|
||||
.setPositiveButton(R.string.forget_only) { _, _ ->
|
||||
removeVolume(volumes[i])
|
||||
removeVolumes(volumes, i + 1, if (dialogBinding.checkboxApplyToAll.isChecked) false else null)
|
||||
}
|
||||
.setNegativeButton(R.string.delete_volume) { _, _ ->
|
||||
PathUtils.recursiveRemoveDirectory(File(volumes[i].getFullPath(filesDir.path)))
|
||||
removeVolume(volumes[i])
|
||||
removeVolumes(volumes, i + 1, if (dialogBinding.checkboxApplyToAll.isChecked) true else null)
|
||||
}
|
||||
.setOnCancelListener {
|
||||
volumeAdapter.refresh()
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
if (doDeleteVolumeContent) {
|
||||
PathUtils.recursiveRemoveDirectory(File(volumes[i].getFullPath(filesDir.path)))
|
||||
}
|
||||
removeVolume(volumes[i])
|
||||
removeVolumes(volumes, i + 1, doDeleteVolumeContent)
|
||||
}
|
||||
} else {
|
||||
removeVolume(volumes[i])
|
||||
removeVolumes(volumes, i + 1, doDeleteVolumeContent)
|
||||
}
|
||||
} else {
|
||||
volumeAdapter.refresh()
|
||||
invalidateOptionsMenu()
|
||||
if (volumeAdapter.volumes.isEmpty()) {
|
||||
binding.textNoVolumes.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unsetDefaultVolume() {
|
||||
with (sharedPrefs.edit()) {
|
||||
remove(DEFAULT_VOLUME_KEY)
|
||||
apply()
|
||||
}
|
||||
volumeOpener.defaultVolumeName = null
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.menu_settings -> {
|
||||
android.R.id.home -> {
|
||||
if (explorerRouter.pickMode || explorerRouter.dropMode) {
|
||||
finish()
|
||||
} else {
|
||||
unselectAll()
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.select_all -> {
|
||||
volumeAdapter.selectAll()
|
||||
invalidateOptionsMenu()
|
||||
true
|
||||
}
|
||||
R.id.lock -> {
|
||||
volumeAdapter.selectedItems.forEach {
|
||||
volumeManager.getVolumeId(volumeAdapter.volumes[it])?.let { id ->
|
||||
volumeManager.closeVolume(id)
|
||||
}
|
||||
}
|
||||
unselectAll()
|
||||
true
|
||||
}
|
||||
R.id.remove -> {
|
||||
val selectedVolumes = volumeAdapter.selectedItems.map { i -> volumeAdapter.volumes[i] }
|
||||
removeVolumes(selectedVolumes)
|
||||
true
|
||||
}
|
||||
R.id.delete_password_hash -> {
|
||||
for (i in volumeAdapter.selectedItems) {
|
||||
if (volumeDatabase.removeHash(volumeAdapter.volumes[i]))
|
||||
volumeAdapter.onVolumeDataChanged(i)
|
||||
}
|
||||
unselectAll(false)
|
||||
true
|
||||
}
|
||||
R.id.change_password -> {
|
||||
selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
|
||||
changePassword.launch(Intent(this, ChangePasswordActivity::class.java).apply {
|
||||
putExtra("volume", volumeAdapter.volumes[selectedVolumePosition!!])
|
||||
})
|
||||
true
|
||||
}
|
||||
R.id.remove_default_open -> {
|
||||
unsetDefaultVolume()
|
||||
unselect(volumeAdapter.selectedItems.first())
|
||||
true
|
||||
}
|
||||
R.id.copy -> {
|
||||
selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
|
||||
val volume = volumeAdapter.volumes[selectedVolumePosition!!]
|
||||
if (volume.isHidden) {
|
||||
(application as VolumeManagerApp).isStartingExternalApp = true
|
||||
PathUtils.safePickDirectory(pickDirectory, this, theme)
|
||||
} else {
|
||||
val hiddenVolumeFile = File(VolumeData.getHiddenVolumeFullPath(filesDir.path, volume.shortName))
|
||||
if (hiddenVolumeFile.exists()) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.hidden_volume_already_exists)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
unselect(selectedVolumePosition!!)
|
||||
copyVolume(
|
||||
DocumentFile.fromFile(File(volume.name)),
|
||||
DocumentFile.fromFile(hiddenVolumeFile.parentFile!!),
|
||||
) {
|
||||
VolumeData(
|
||||
VolumeData.newUuid(),
|
||||
volume.shortName,
|
||||
true,
|
||||
volume.type,
|
||||
volume.encryptedHash,
|
||||
volume.iv
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.rename -> {
|
||||
val position = volumeAdapter.selectedItems.elementAt(0)
|
||||
renameVolume(volumeAdapter.volumes[position], position)
|
||||
true
|
||||
}
|
||||
R.id.settings -> {
|
||||
val intent = Intent(this, SettingsActivity::class.java)
|
||||
startActivity(intent)
|
||||
true
|
||||
@ -54,11 +344,166 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.main_activity, menu)
|
||||
val settingsVisible = !explorerRouter.pickMode && !explorerRouter.dropMode
|
||||
menu.findItem(R.id.settings).isVisible = settingsVisible
|
||||
if (settingsVisible) {
|
||||
UIUtils.getMenuIconNeutralTint(this, menu).applyTo(R.id.settings, R.drawable.icon_settings)
|
||||
}
|
||||
val isSelecting = volumeAdapter.selectedItems.isNotEmpty()
|
||||
menu.findItem(R.id.select_all).isVisible = isSelecting
|
||||
menu.findItem(R.id.lock).isVisible = isSelecting && volumeAdapter.selectedItems.any {
|
||||
i -> volumeManager.isOpen(volumeAdapter.volumes[i])
|
||||
}
|
||||
menu.findItem(R.id.remove).isVisible = isSelecting
|
||||
menu.findItem(R.id.delete_password_hash).isVisible =
|
||||
isSelecting &&
|
||||
!volumeAdapter.selectedItems.any { i -> volumeAdapter.volumes[i].encryptedHash == null }
|
||||
val onlyOneSelected = volumeAdapter.selectedItems.size == 1
|
||||
val onlyOneAndWriteable =
|
||||
onlyOneSelected &&
|
||||
volumeAdapter.volumes[volumeAdapter.selectedItems.first()].canWrite(filesDir.path)
|
||||
menu.findItem(R.id.change_password).isVisible = onlyOneAndWriteable
|
||||
menu.findItem(R.id.remove_default_open).isVisible =
|
||||
onlyOneSelected &&
|
||||
volumeAdapter.volumes[volumeAdapter.selectedItems.first()].name == volumeOpener.defaultVolumeName
|
||||
with(menu.findItem(R.id.copy)) {
|
||||
isVisible = onlyOneSelected
|
||||
if (isVisible) {
|
||||
setTitle(if (volumeAdapter.volumes[volumeAdapter.selectedItems.elementAt(0)].isHidden)
|
||||
R.string.copy_hidden_volume
|
||||
else
|
||||
R.string.copy_external_volume
|
||||
)
|
||||
}
|
||||
}
|
||||
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable && !volumeManager.isOpen(volumeAdapter.volumes[volumeAdapter.selectedItems.first()])
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode)
|
||||
return true
|
||||
}
|
||||
|
||||
fun <T> startActivity(clazz: Class<T>) {
|
||||
val intent = Intent(this, clazz)
|
||||
startActivity(intent)
|
||||
private fun onDirectoryPicked(uri: Uri) {
|
||||
val volume = volumeAdapter.volumes[selectedVolumePosition!!]
|
||||
unselect(selectedVolumePosition!!)
|
||||
val dstDocumentFile = DocumentFile.fromTreeUri(this, uri)
|
||||
if (dstDocumentFile == null) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.path_error)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
copyVolume(
|
||||
DocumentFile.fromFile(File(volume.getFullPath(filesDir.path))),
|
||||
dstDocumentFile,
|
||||
) { dstRootDirectory ->
|
||||
dstRootDirectory.name?.let { name ->
|
||||
val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this)
|
||||
if (path == null) null
|
||||
else VolumeData(
|
||||
VolumeData.newUuid(),
|
||||
PathUtils.pathJoin(path, name),
|
||||
false,
|
||||
volume.type,
|
||||
volume.encryptedHash,
|
||||
volume.iv
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a volume.
|
||||
*
|
||||
* @param srcDocumentFile [DocumentFile] of the volume to copy
|
||||
* @param dstDocumentFile [DocumentFile] of the destination PARENT FOLDER
|
||||
* @param getResultVolume A function that returns the [VolumeData] corresponding to the destination volume. Takes the [DocumentFile] of the newly created volume (not the parent folder).
|
||||
*/
|
||||
private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> VolumeData?) {
|
||||
lifecycleScope.launch {
|
||||
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
|
||||
when (result.taskResult.state) {
|
||||
TaskResult.State.SUCCESS -> {
|
||||
result.dstRootDirectory?.let {
|
||||
getResultVolume(it)?.let { volume ->
|
||||
volumeDatabase.saveVolume(volume)
|
||||
volumeAdapter.apply {
|
||||
volumes = volumeDatabase.getVolumes()
|
||||
notifyItemInserted(volumes.size)
|
||||
}
|
||||
binding.textNoVolumes.visibility = View.GONE
|
||||
Toast.makeText(this@MainActivity, R.string.copy_success, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
TaskResult.State.FAILED -> {
|
||||
CustomAlertDialogBuilder(this@MainActivity, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem!!.name))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
|
||||
TaskResult.State.CANCELLED -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renameVolume(volume: VolumeData, position: Int) {
|
||||
with (EditTextDialog(this, R.string.new_volume_name) { newName ->
|
||||
val srcPath = File(volume.getFullPath(filesDir.path))
|
||||
val dstPath = File(srcPath.parent, newName).canonicalFile
|
||||
val newDBName: String
|
||||
val success = if (volume.isHidden) {
|
||||
if (newName.contains(PathUtils.SEPARATOR)) {
|
||||
Toast.makeText(this, R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
|
||||
renameVolume(volume, position)
|
||||
return@EditTextDialog
|
||||
}
|
||||
newDBName = newName
|
||||
srcPath.renameTo(dstPath)
|
||||
} else {
|
||||
newDBName = dstPath.path
|
||||
DocumentFile.fromFile(srcPath).renameTo(newName)
|
||||
}
|
||||
if (success) {
|
||||
volumeDatabase.renameVolume(volume, newDBName)
|
||||
VolumeProvider.notifyRootsChanged(this)
|
||||
volumeAdapter.onVolumeDataChanged(position)
|
||||
unselect(position)
|
||||
if (volume.name == volumeOpener.defaultVolumeName) {
|
||||
with (sharedPrefs.edit()) {
|
||||
putString(DEFAULT_VOLUME_KEY, newDBName)
|
||||
apply()
|
||||
}
|
||||
volumeOpener.defaultVolumeName = newDBName
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(this, R.string.volume_rename_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}) {
|
||||
setSelectedText(volume.shortName)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openVolume(volume: VolumeData) {
|
||||
volumeOpener.openVolume(volume, true, object : VolumeOpener.VolumeOpenerCallbacks {
|
||||
override fun onHashStorageReset() {
|
||||
volumeAdapter.refresh()
|
||||
}
|
||||
|
||||
override fun onVolumeOpened(id: Int) {
|
||||
startActivity(explorerRouter.getExplorerIntent(id, volume.shortName))
|
||||
if (explorerRouter.dropMode || explorerRouter.pickMode) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
volumeOpener.wipeSensitive()
|
||||
}
|
||||
}
|
||||
|
22
app/src/main/java/sushi/hardcore/droidfs/MemFile.kt
Normal file
22
app/src/main/java/sushi/hardcore/droidfs/MemFile.kt
Normal 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)
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import sushi.hardcore.droidfs.file_operations.FileOperationService
|
||||
|
||||
class NotificationBroadcastReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
FileOperationService.ACTION_CANCEL -> {
|
||||
intent.getBundleExtra("bundle")?.let { bundle ->
|
||||
// TODO: use peekService instead?
|
||||
val binder = (bundle.getBinder("binder") as FileOperationService.LocalBinder?)
|
||||
binder?.getService()?.cancelOperation(bundle.getInt("taskId"))
|
||||
}
|
||||
}
|
||||
VolumeManagerApp.ACTION_CLOSE_ALL_VOLUMES -> {
|
||||
(context.applicationContext as VolumeManagerApp).volumeManager.closeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,265 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.MenuItem
|
||||
import android.widget.AdapterView.OnItemClickListener
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import sushi.hardcore.droidfs.adapters.SavedVolumesAdapter
|
||||
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
|
||||
import sushi.hardcore.droidfs.databinding.ActivityOpenBinding
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerActivity
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerActivityDrop
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerActivityPick
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.util.WidgetUtil
|
||||
import sushi.hardcore.droidfs.util.Wiper
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
class OpenActivity : VolumeActionActivity() {
|
||||
private lateinit var savedVolumesAdapter: SavedVolumesAdapter
|
||||
private var sessionID = -1
|
||||
private var isStartingActivity = false
|
||||
private var isFinishingIntentionally = false
|
||||
private lateinit var binding: ActivityOpenBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityOpenBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setupLayout()
|
||||
setupFingerprintStuff()
|
||||
savedVolumesAdapter = SavedVolumesAdapter(this, volumeDatabase)
|
||||
if (savedVolumesAdapter.count > 0){
|
||||
binding.savedPathListview.adapter = savedVolumesAdapter
|
||||
binding.savedPathListview.onItemClickListener = OnItemClickListener { _, _, position, _ ->
|
||||
val volume = savedVolumesAdapter.getItem(position)
|
||||
currentVolumeName = volume.name
|
||||
if (volume.isHidden){
|
||||
switchHiddenVolume.isChecked = true
|
||||
editVolumeName.setText(currentVolumeName)
|
||||
} else {
|
||||
switchHiddenVolume.isChecked = false
|
||||
editVolumePath.setText(currentVolumeName)
|
||||
}
|
||||
onClickSwitchHiddenVolume()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
|
||||
volume.hash?.let { hash ->
|
||||
volume.iv?.let { iv ->
|
||||
currentVolumePath = if (volume.isHidden){
|
||||
PathUtils.pathJoin(filesDir.path, volume.name)
|
||||
} else {
|
||||
volume.name
|
||||
}
|
||||
loadPasswordHash(hash, iv, ::openUsingPasswordHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
WidgetUtil.hideWithPadding(binding.savedPathListview)
|
||||
}
|
||||
val textWatcher = object: TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
|
||||
}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||
if (volumeDatabase.isVolumeSaved(s.toString())){
|
||||
checkboxRememberPath.isEnabled = false
|
||||
checkboxRememberPath.isChecked = true
|
||||
if (volumeDatabase.isHashSaved(s.toString())){
|
||||
checkboxSavePassword.isEnabled = false
|
||||
checkboxSavePassword.isChecked = true
|
||||
} else {
|
||||
checkboxSavePassword.isEnabled = true
|
||||
}
|
||||
} else {
|
||||
checkboxRememberPath.isEnabled = true
|
||||
checkboxSavePassword.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
editVolumePath.addTextChangedListener(textWatcher)
|
||||
editVolumeName.addTextChangedListener(textWatcher)
|
||||
binding.editPassword.setOnEditorActionListener { _, _, _ ->
|
||||
checkVolumePathThenOpen()
|
||||
true
|
||||
}
|
||||
binding.buttonOpen.setOnClickListener {
|
||||
checkVolumePathThenOpen()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when(item.itemId){
|
||||
android.R.id.home -> {
|
||||
isFinishingIntentionally = true
|
||||
finish()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPickingDirectory() {
|
||||
isStartingActivity = true
|
||||
}
|
||||
|
||||
fun checkVolumePathThenOpen() {
|
||||
loadVolumePath {
|
||||
val volumeFile = File(currentVolumePath)
|
||||
if (!GocryptfsVolume.isGocryptfsVolume(volumeFile)){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.error_not_a_volume)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else if (!volumeFile.canWrite()) {
|
||||
if ((intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) && intent.extras != null) { //import via android share menu
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.open_cant_write_error_msg)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
val dialog = ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.warning)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> openVolume() }
|
||||
if (PathUtils.isPathOnExternalStorage(currentVolumeName, this)){
|
||||
dialog.setView(
|
||||
layoutInflater.inflate(R.layout.dialog_sdcard_error, null).apply {
|
||||
findViewById<TextView>(R.id.path).text = PathUtils.getPackageDataFolder(this@OpenActivity)
|
||||
findViewById<TextView>(R.id.footer).text = getString(R.string.open_read_only)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
dialog.setMessage(R.string.open_cant_write_warning)
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
} else {
|
||||
openVolume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openVolume(){
|
||||
object : LoadingTask(this, R.string.loading_msg_open){
|
||||
override fun doTask(activity: AppCompatActivity) {
|
||||
val password = binding.editPassword.text.toString().toCharArray()
|
||||
var returnedHash: ByteArray? = null
|
||||
if (checkboxSavePassword.isChecked && usf_fingerprint) {
|
||||
returnedHash = ByteArray(GocryptfsVolume.KeyLen)
|
||||
}
|
||||
sessionID = GocryptfsVolume.init(currentVolumePath, password, null, returnedHash)
|
||||
if (sessionID != -1) {
|
||||
if (checkboxRememberPath.isChecked) {
|
||||
volumeDatabase.saveVolume(Volume(currentVolumeName, switchHiddenVolume.isChecked))
|
||||
}
|
||||
if (checkboxSavePassword.isChecked && returnedHash != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M){
|
||||
stopTask {
|
||||
savePasswordHash(returnedHash) { success ->
|
||||
if (success){
|
||||
startExplorer()
|
||||
} else {
|
||||
GocryptfsVolume(sessionID).close()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopTask { startExplorer() }
|
||||
}
|
||||
} else {
|
||||
stopTask {
|
||||
ColoredAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.open_volume_failed)
|
||||
.setMessage(R.string.open_volume_failed_msg)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
Arrays.fill(password, 0.toChar())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUsingPasswordHash(passwordHash: ByteArray){
|
||||
object : LoadingTask(this, R.string.loading_msg_open){
|
||||
override fun doTask(activity: AppCompatActivity) {
|
||||
sessionID = GocryptfsVolume.init(currentVolumePath, null, passwordHash, null)
|
||||
if (sessionID != -1){
|
||||
stopTask { startExplorer() }
|
||||
} else {
|
||||
stopTask {
|
||||
ColoredAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.open_volume_failed)
|
||||
.setMessage(R.string.open_failed_hash_msg)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
Arrays.fill(passwordHash, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startExplorer() {
|
||||
var explorerIntent: Intent? = null
|
||||
val currentIntentAction = intent.action
|
||||
if (currentIntentAction != null) {
|
||||
if ((currentIntentAction == Intent.ACTION_SEND || currentIntentAction == Intent.ACTION_SEND_MULTIPLE) && intent.extras != null) { //import via android share menu
|
||||
explorerIntent = Intent(this, ExplorerActivityDrop::class.java)
|
||||
explorerIntent.action = currentIntentAction //forward action
|
||||
explorerIntent.putExtras(intent.extras!!) //forward extras
|
||||
} else if (currentIntentAction == "pick") { //pick items to import
|
||||
explorerIntent = Intent(this, ExplorerActivityPick::class.java)
|
||||
explorerIntent.putExtra("originalSessionID", intent.getIntExtra("sessionID", -1))
|
||||
explorerIntent.flags = Intent.FLAG_ACTIVITY_FORWARD_RESULT
|
||||
}
|
||||
}
|
||||
if (explorerIntent == null) {
|
||||
explorerIntent = Intent(this, ExplorerActivity::class.java) //default opening
|
||||
}
|
||||
explorerIntent.putExtra("sessionID", sessionID)
|
||||
explorerIntent.putExtra("volume_name", File(currentVolumeName).name)
|
||||
startActivity(explorerIntent)
|
||||
isFinishingIntentionally = true
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
super.onBackPressed()
|
||||
isFinishingIntentionally = true
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (intent.action == "pick"){
|
||||
if (isStartingActivity) {
|
||||
isStartingActivity = false
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Wiper.wipeEditText(binding.editPassword)
|
||||
if (intent.action == "pick" && !isFinishingIntentionally){
|
||||
val sessionID = intent.getIntExtra("sessionID", -1)
|
||||
if (sessionID != -1){
|
||||
GocryptfsVolume(sessionID).close()
|
||||
RestrictedFileProvider.wipeAll(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.view.Surface
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
|
||||
@ -15,8 +16,8 @@ class SensorOrientationListener(context: Context) {
|
||||
mSensorEventListener = NotifierSensorEventListener()
|
||||
mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
}
|
||||
private val mListeners = ArrayList<WeakReference<Listener?>>(3)
|
||||
private var orientation = 0
|
||||
private val mListeners = ArrayList<WeakReference<Listener?>>(1)
|
||||
private var orientation = Surface.ROTATION_0
|
||||
|
||||
private fun onResume() {
|
||||
mSensorManager.registerListener(
|
||||
@ -35,10 +36,10 @@ class SensorOrientationListener(context: Context) {
|
||||
val x = event.values[0]
|
||||
val y = event.values[1]
|
||||
var newOrientation: Int = orientation
|
||||
if (x < 5 && x > -5 && y > 5) newOrientation = 0
|
||||
else if (x < -5 && y < 5 && y > -5) newOrientation = 90
|
||||
else if (x < 5 && x > -5 && y < -5) newOrientation = 180
|
||||
else if (x > 5 && y < 5 && y > -5) newOrientation = 270
|
||||
if (x < 5 && x > -5 && y > 5) newOrientation = Surface.ROTATION_0
|
||||
else if (x < -5 && y < 5 && y > -5) newOrientation = Surface.ROTATION_270
|
||||
else if (x < 5 && x > -5 && y < -5) newOrientation = Surface.ROTATION_180
|
||||
else if (x > 5 && y < 5 && y > -5) newOrientation = Surface.ROTATION_90
|
||||
|
||||
if (orientation != newOrientation) {
|
||||
orientation = newOrientation
|
||||
|
@ -1,22 +1,35 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import com.jaredrummler.android.colorpicker.ColorPreferenceCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.preference.SwitchPreference
|
||||
import androidx.preference.SwitchPreferenceCompat
|
||||
import sushi.hardcore.droidfs.content_providers.VolumeProvider
|
||||
import sushi.hardcore.droidfs.databinding.ActivitySettingsBinding
|
||||
import sushi.hardcore.droidfs.widgets.SimpleActionPreference
|
||||
import sushi.hardcore.droidfs.widgets.ThemeColor
|
||||
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||
import sushi.hardcore.droidfs.util.Compat
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||
|
||||
class SettingsActivity : BaseActivity() {
|
||||
private val notificationPermissionHelper = AndroidUtils.NotificationPermissionHelper(this)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val binding = ActivitySettingsBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
setSupportActionBar(binding.toolbar.toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
val screen = intent.extras?.getString("screen") ?: "main"
|
||||
val fragment = if (screen == "UnsafeFeaturesSettingsFragment") {
|
||||
@ -33,7 +46,7 @@ class SettingsActivity : BaseActivity() {
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId){
|
||||
android.R.id.home -> {
|
||||
onBackPressed() //return to the previous fragment rather than the activity
|
||||
onBackPressedDispatcher.onBackPressed() //return to the previous fragment rather than the activity
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
@ -41,36 +54,185 @@ class SettingsActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
class MainSettingsFragment : PreferenceFragmentCompat() {
|
||||
private lateinit var sharedPrefs: SharedPreferences
|
||||
private lateinit var maxSizePreference: Preference
|
||||
|
||||
private fun setThumbnailMaxSize(input: String) {
|
||||
val value: Long
|
||||
try {
|
||||
value = input.toLong()
|
||||
} catch (e: NumberFormatException) {
|
||||
Toast.makeText(requireContext(), R.string.invalid_number, Toast.LENGTH_SHORT).show()
|
||||
showMaxSizeDialog()
|
||||
return
|
||||
}
|
||||
val size = value*1000
|
||||
if (size < 0) {
|
||||
Toast.makeText(requireContext(), R.string.invalid_number, Toast.LENGTH_SHORT).show()
|
||||
showMaxSizeDialog()
|
||||
} else {
|
||||
with(sharedPrefs.edit()) {
|
||||
putLong(Constants.THUMBNAIL_MAX_SIZE_KEY, value)
|
||||
apply()
|
||||
}
|
||||
maxSizePreference.summary = PathUtils.formatSize(size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMaxSizeDialog() {
|
||||
with (EditTextDialog((requireActivity() as BaseActivity), R.string.thumbnail_max_size) {
|
||||
setThumbnailMaxSize(it)
|
||||
}) {
|
||||
with (binding.dialogEditText) {
|
||||
inputType = InputType.TYPE_CLASS_NUMBER
|
||||
hint = getString(R.string.size_hint)
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshTheme() {
|
||||
with(requireActivity()) {
|
||||
startActivity(
|
||||
Intent(this, SettingsActivity::class.java),
|
||||
ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
android.R.anim.fade_in,
|
||||
android.R.anim.fade_out
|
||||
).toBundle()
|
||||
)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.root_preferences, rootKey)
|
||||
ThemeColor.tintPreferenceIcons(preferenceScreen, ThemeColor.getThemeColor(requireContext()))
|
||||
var originalThemeColor: Int? = null
|
||||
context?.let {
|
||||
originalThemeColor = ContextCompat.getColor(it, R.color.themeColor)
|
||||
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
findPreference<ListPreference>("color")?.setOnPreferenceChangeListener { _, _ ->
|
||||
refreshTheme()
|
||||
true
|
||||
}
|
||||
findPreference<ColorPreferenceCompat>("themeColor")?.let { colorPicker ->
|
||||
colorPicker.onPreferenceChangeListener = Preference.OnPreferenceChangeListener{ _, _ ->
|
||||
(activity as SettingsActivity).changeThemeColor()
|
||||
true
|
||||
}
|
||||
findPreference<SimpleActionPreference>("resetThemeColor")?.onClick = {
|
||||
originalThemeColor?.let {
|
||||
colorPicker.saveValue(it)
|
||||
val settingsActivity = (activity as SettingsActivity)
|
||||
Thread {
|
||||
settingsActivity.sharedPrefs.edit().commit()
|
||||
settingsActivity.runOnUiThread { settingsActivity.changeThemeColor() }
|
||||
}.start()
|
||||
}
|
||||
findPreference<SwitchPreferenceCompat>("black_theme")?.setOnPreferenceChangeListener { _, _ ->
|
||||
refreshTheme()
|
||||
true
|
||||
}
|
||||
findPreference<Preference>(Constants.THUMBNAIL_MAX_SIZE_KEY)?.let {
|
||||
maxSizePreference = it
|
||||
maxSizePreference.summary = getString(
|
||||
R.string.thumbnail_max_size_summary,
|
||||
PathUtils.formatSize(sharedPrefs.getLong(
|
||||
Constants.THUMBNAIL_MAX_SIZE_KEY, Constants.DEFAULT_THUMBNAIL_MAX_SIZE
|
||||
)*1000)
|
||||
)
|
||||
maxSizePreference.setOnPreferenceClickListener {
|
||||
showMaxSizeDialog()
|
||||
false
|
||||
}
|
||||
}
|
||||
findPreference<Preference>("logcat")?.setOnPreferenceClickListener { _ ->
|
||||
startActivity(Intent(requireContext(), LogcatActivity::class.java))
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UnsafeFeaturesSettingsFragment : PreferenceFragmentCompat() {
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
setPreferencesFromResource(R.xml.unsafe_features_preferences, rootKey)
|
||||
ThemeColor.tintPreferenceIcons(preferenceScreen, ThemeColor.getThemeColor(requireContext()))
|
||||
findPreference<SwitchPreference>("usf_fingerprint")?.setOnPreferenceChangeListener { _, checked ->
|
||||
if (checked as Boolean) {
|
||||
var errorMsg: String? = null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val reason = when (FingerprintProtector.canAuthenticate(requireContext())) {
|
||||
0 -> null
|
||||
1 -> R.string.keyguard_not_secure
|
||||
2 -> R.string.no_hardware
|
||||
3 -> R.string.hardware_unavailable
|
||||
4 -> R.string.no_fingerprint
|
||||
else -> R.string.unknown_error
|
||||
}
|
||||
reason?.let {
|
||||
errorMsg = getString(R.string.fingerprint_error_msg, getString(it))
|
||||
}
|
||||
} else {
|
||||
errorMsg = getString(R.string.error_marshmallow_required)
|
||||
}
|
||||
if (errorMsg == null) {
|
||||
true
|
||||
} else {
|
||||
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(errorMsg)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
val switchBackground = findPreference<SwitchPreference>("usf_background")!!
|
||||
val switchKeepOpen = findPreference<SwitchPreference>("usf_keep_open")!!
|
||||
val switchExternalOpen = findPreference<SwitchPreference>("usf_open")!!
|
||||
val switchExpose = findPreference<SwitchPreference>("usf_expose")!!
|
||||
val switchSafWrite = findPreference<SwitchPreference>("usf_saf_write")!!
|
||||
|
||||
fun onUsfBackgroundChanged(usfBackground: Boolean) {
|
||||
fun updateSwitchPreference(switch: SwitchPreference) = with (switch) {
|
||||
isChecked = isChecked && usfBackground
|
||||
isEnabled = usfBackground
|
||||
onPreferenceChangeListener?.onPreferenceChange(switch, isChecked)
|
||||
}
|
||||
updateSwitchPreference(switchKeepOpen)
|
||||
updateSwitchPreference(switchExpose)
|
||||
}
|
||||
onUsfBackgroundChanged(switchBackground.isChecked)
|
||||
|
||||
fun updateSafWrite(usfOpen: Boolean? = null, usfExpose: Boolean? = null) {
|
||||
switchSafWrite.isEnabled = usfOpen ?: switchExternalOpen.isChecked || usfExpose ?: switchExpose.isChecked
|
||||
}
|
||||
updateSafWrite()
|
||||
|
||||
switchBackground.setOnPreferenceChangeListener { _, checked ->
|
||||
onUsfBackgroundChanged(checked as Boolean)
|
||||
true
|
||||
}
|
||||
switchExternalOpen.setOnPreferenceChangeListener { _, checked ->
|
||||
updateSafWrite(usfOpen = checked as Boolean)
|
||||
true
|
||||
}
|
||||
switchExpose.setOnPreferenceChangeListener { _, checked ->
|
||||
updateSafWrite(usfExpose = checked as Boolean)
|
||||
VolumeProvider.notifyRootsChanged(requireContext())
|
||||
true
|
||||
}
|
||||
|
||||
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
|
||||
if (checked as Boolean) {
|
||||
(requireActivity() as SettingsActivity).notificationPermissionHelper.askAndRun {
|
||||
requireContext().let {
|
||||
if (AndroidUtils.isServiceRunning(it, KeepAliveService::class.java)) {
|
||||
ContextCompat.startForegroundService(it, Intent(it, KeepAliveService::class.java).apply {
|
||||
action = KeepAliveService.ACTION_FOREGROUND
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
findPreference<ListPreference>("export_method")!!.setOnPreferenceChangeListener { _, newValue ->
|
||||
if (newValue as String == "memory" && !Compat.isMemFileSupported()) {
|
||||
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.memfd_create_unsupported, Compat.MEMFD_CREATE_MINIMUM_KERNEL_VERSION))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
return@setOnPreferenceChangeListener false
|
||||
}
|
||||
EncryptedFileProvider.exportMethod = EncryptedFileProvider.ExportMethod.parse(newValue)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
68
app/src/main/java/sushi/hardcore/droidfs/Theme.kt
Normal file
68
app/src/main/java/sushi/hardcore/droidfs/Theme.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
92
app/src/main/java/sushi/hardcore/droidfs/ThumbnailsLoader.kt
Normal file
92
app/src/main/java/sushi/hardcore/droidfs/ThumbnailsLoader.kt
Normal 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)
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
class Volume(val name: String, val isHidden: Boolean = false, var hash: ByteArray? = null, var iv: ByteArray? = null)
|
@ -1,398 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.Manifest
|
||||
import android.app.KeyguardManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.widget.*
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.widget.SwitchCompat
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.util.WidgetUtil
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.ColoredImageButton
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.*
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
abstract class VolumeActionActivity : BaseActivity() {
|
||||
|
||||
companion object {
|
||||
private const val STORAGE_PERMISSIONS_REQUEST = 0
|
||||
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||
private const val KEY_ALIAS = "Hash Key"
|
||||
private const val KEY_SIZE = 256
|
||||
private const val GCM_TAG_LEN = 128
|
||||
}
|
||||
|
||||
protected lateinit var currentVolumeName: String
|
||||
protected lateinit var currentVolumePath: String
|
||||
protected lateinit var volumeDatabase: VolumeDatabase
|
||||
protected val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
onDirectoryPicked(uri)
|
||||
}
|
||||
}
|
||||
protected var usf_fingerprint = false
|
||||
private var biometricCanAuthenticateCode: Int = -1
|
||||
private lateinit var biometricManager: BiometricManager
|
||||
private lateinit var biometricPrompt: BiometricPrompt
|
||||
private lateinit var keyStore: KeyStore
|
||||
private lateinit var key: SecretKey
|
||||
private lateinit var cipher: Cipher
|
||||
private var isCipherReady = false
|
||||
private var actionMode: Int? = null
|
||||
private lateinit var onAuthenticationResult: (success: Boolean) -> Unit
|
||||
private lateinit var onPasswordDecrypted: (password: ByteArray) -> Unit
|
||||
private lateinit var dataToProcess: ByteArray
|
||||
private lateinit var originalHiddenVolumeSectionLayoutParams: LinearLayout.LayoutParams
|
||||
private lateinit var originalNormalVolumeSectionLayoutParams: LinearLayout.LayoutParams
|
||||
protected lateinit var switchHiddenVolume: SwitchCompat
|
||||
protected lateinit var checkboxRememberPath: CheckBox
|
||||
protected lateinit var checkboxSavePassword: CheckBox
|
||||
protected lateinit var editVolumeName: EditText
|
||||
protected lateinit var editVolumePath: EditText
|
||||
private lateinit var hiddenVolumeSection: LinearLayout
|
||||
private lateinit var normalVolumeSection: LinearLayout
|
||||
|
||||
protected fun setupLayout() {
|
||||
setSupportActionBar(findViewById(R.id.toolbar))
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
findViewById<ColoredImageButton>(R.id.button_pick_directory).setOnClickListener {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) +
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), STORAGE_PERMISSIONS_REQUEST)
|
||||
} else {
|
||||
safePickDirectory()
|
||||
}
|
||||
} else {
|
||||
safePickDirectory()
|
||||
}
|
||||
}
|
||||
switchHiddenVolume = findViewById(R.id.switch_hidden_volume)
|
||||
checkboxRememberPath = findViewById(R.id.checkbox_remember_path)
|
||||
checkboxSavePassword = findViewById(R.id.checkbox_save_password)
|
||||
editVolumeName = findViewById(R.id.edit_volume_name)
|
||||
editVolumePath = findViewById(R.id.edit_volume_path)
|
||||
hiddenVolumeSection = findViewById(R.id.hidden_volume_section)
|
||||
normalVolumeSection = findViewById(R.id.normal_volume_section)
|
||||
switchHiddenVolume.setOnClickListener {
|
||||
onClickSwitchHiddenVolume()
|
||||
}
|
||||
checkboxRememberPath.setOnClickListener {
|
||||
if (!checkboxRememberPath.isChecked) {
|
||||
checkboxSavePassword.isChecked = false
|
||||
}
|
||||
}
|
||||
checkboxSavePassword.setOnClickListener {
|
||||
if (checkboxSavePassword.isChecked) {
|
||||
if (biometricCanAuthenticateCode == 0) {
|
||||
checkboxRememberPath.isChecked = true
|
||||
} else {
|
||||
checkboxSavePassword.isChecked = false
|
||||
printAuthenticateImpossibleError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun onClickSwitchHiddenVolume() {
|
||||
if (switchHiddenVolume.isChecked){
|
||||
WidgetUtil.show(hiddenVolumeSection, originalHiddenVolumeSectionLayoutParams)
|
||||
WidgetUtil.hide(normalVolumeSection)
|
||||
} else {
|
||||
WidgetUtil.show(normalVolumeSection, originalNormalVolumeSectionLayoutParams)
|
||||
WidgetUtil.hide(hiddenVolumeSection)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun onPickingDirectory() {}
|
||||
protected fun onDirectoryPicked(uri: Uri) {
|
||||
val path = PathUtils.getFullPathFromTreeUri(uri, this)
|
||||
if (path != null) {
|
||||
editVolumePath.setText(path)
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.path_from_uri_null_error_msg)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun safePickDirectory() {
|
||||
try {
|
||||
onPickingDirectory()
|
||||
pickDirectory.launch(null)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.open_tree_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
when (requestCode) {
|
||||
STORAGE_PERMISSIONS_REQUEST -> if (grantResults.size == 2) {
|
||||
if (grantResults[0] != PackageManager.PERMISSION_GRANTED || grantResults[1] != PackageManager.PERMISSION_GRANTED) {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.storage_perm_denied)
|
||||
.setMessage(R.string.storage_perm_denied_msg)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
safePickDirectory()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun setupFingerprintStuff(mayDecrypt: Boolean = true) {
|
||||
originalHiddenVolumeSectionLayoutParams = hiddenVolumeSection.layoutParams as LinearLayout.LayoutParams
|
||||
originalNormalVolumeSectionLayoutParams = normalVolumeSection.layoutParams as LinearLayout.LayoutParams
|
||||
WidgetUtil.hide(hiddenVolumeSection)
|
||||
volumeDatabase = VolumeDatabase(this)
|
||||
usf_fingerprint = sharedPrefs.getBoolean("usf_fingerprint", false)
|
||||
val marshmallow = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||
if (!marshmallow || !usf_fingerprint) {
|
||||
WidgetUtil.hideWithPadding(checkboxSavePassword)
|
||||
}
|
||||
if (marshmallow && (mayDecrypt || usf_fingerprint)) {
|
||||
biometricManager = BiometricManager.from(this)
|
||||
biometricCanAuthenticateCode = canAuthenticate()
|
||||
if (biometricCanAuthenticateCode == 0){
|
||||
val executor = ContextCompat.getMainExecutor(this)
|
||||
val activityContext = this
|
||||
val callback = object: BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
Toast.makeText(applicationContext, errString, Toast.LENGTH_SHORT).show()
|
||||
if (actionMode == Cipher.ENCRYPT_MODE){
|
||||
onAuthenticationResult(false)
|
||||
}
|
||||
}
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
Toast.makeText(applicationContext, R.string.authentication_failed, Toast.LENGTH_SHORT).show()
|
||||
if (actionMode == Cipher.ENCRYPT_MODE){
|
||||
onAuthenticationResult(false)
|
||||
}
|
||||
}
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
var success = false
|
||||
val cipherObject = result.cryptoObject?.cipher
|
||||
if (cipherObject != null){
|
||||
try {
|
||||
when (actionMode) {
|
||||
Cipher.ENCRYPT_MODE -> {
|
||||
val cipherText = cipherObject.doFinal(dataToProcess)
|
||||
success = volumeDatabase.addHash(Volume(currentVolumeName, switchHiddenVolume.isChecked, cipherText, cipherObject.iv))
|
||||
}
|
||||
Cipher.DECRYPT_MODE -> {
|
||||
try {
|
||||
val plainText = cipherObject.doFinal(dataToProcess)
|
||||
onPasswordDecrypted(plainText)
|
||||
} catch (e: AEADBadTagException){
|
||||
ColoredAlertDialogBuilder(activityContext)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.MAC_verification_failed)
|
||||
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
|
||||
resetHashStorage()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IllegalBlockSizeException){
|
||||
ColoredAlertDialogBuilder(activityContext)
|
||||
.setTitle(R.string.illegal_block_size_exception)
|
||||
.setMessage(R.string.illegal_block_size_exception_msg)
|
||||
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
|
||||
resetHashStorage()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(applicationContext, R.string.error_cipher_null, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
if (actionMode == Cipher.ENCRYPT_MODE){
|
||||
onAuthenticationResult(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
biometricPrompt = BiometricPrompt(this, executor, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun canAuthenticate(): Int {
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
return if (!keyguardManager.isKeyguardSecure) {
|
||||
1
|
||||
} else {
|
||||
when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)){
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> 0
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> 2
|
||||
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> 3
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> 4
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun printAuthenticateImpossibleError() {
|
||||
Toast.makeText(this, when (biometricCanAuthenticateCode){
|
||||
1 -> R.string.fingerprint_error_no_fingerprints
|
||||
2 -> R.string.fingerprint_error_hw_not_present
|
||||
3 -> R.string.fingerprint_error_hw_not_available
|
||||
4 -> R.string.fingerprint_error_no_fingerprints
|
||||
else -> R.string.error
|
||||
}, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun prepareCipher() {
|
||||
keyStore = KeyStore.getInstance(ANDROID_KEY_STORE)
|
||||
keyStore.load(null)
|
||||
key = if (keyStore.containsAlias(KEY_ALIAS)){
|
||||
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||
} else {
|
||||
val builder = KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
builder.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
builder.setKeySize(KEY_SIZE)
|
||||
builder.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
builder.setUserAuthenticationRequired(true)
|
||||
val keyGenerator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
ANDROID_KEY_STORE
|
||||
)
|
||||
keyGenerator.init(builder.build())
|
||||
keyGenerator.generateKey()
|
||||
}
|
||||
cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES+"/"+KeyProperties.BLOCK_MODE_GCM+"/"+KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
isCipherReady = true
|
||||
}
|
||||
|
||||
private fun alertKeyPermanentlyInvalidatedException(){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.key_permanently_invalidated_exception)
|
||||
.setMessage(R.string.key_permanently_invalidated_exception_msg)
|
||||
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
|
||||
resetHashStorage()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
protected fun savePasswordHash(plainText: ByteArray, onAuthenticationResult: (success: Boolean) -> Unit){
|
||||
val biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(currentVolumeName)
|
||||
.setSubtitle(getString(R.string.encrypt_action_description))
|
||||
.setDescription(getString(R.string.fingerprint_instruction))
|
||||
.setNegativeButtonText(getString(R.string.cancel))
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
if (!isCipherReady){
|
||||
prepareCipher()
|
||||
}
|
||||
actionMode = Cipher.ENCRYPT_MODE
|
||||
try {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
this.onAuthenticationResult = onAuthenticationResult
|
||||
dataToProcess = plainText
|
||||
biometricPrompt.authenticate(biometricPromptInfo, BiometricPrompt.CryptoObject(cipher))
|
||||
} catch (e: KeyPermanentlyInvalidatedException){
|
||||
alertKeyPermanentlyInvalidatedException()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
protected fun loadPasswordHash(cipherText: ByteArray, iv: ByteArray, onPasswordDecrypted: (password: ByteArray) -> Unit){
|
||||
val biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(currentVolumeName)
|
||||
.setSubtitle(getString(R.string.decrypt_action_description))
|
||||
.setDescription(getString(R.string.fingerprint_instruction))
|
||||
.setNegativeButtonText(getString(R.string.cancel))
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||
.setConfirmationRequired(false)
|
||||
.build()
|
||||
this.onPasswordDecrypted = onPasswordDecrypted
|
||||
actionMode = Cipher.DECRYPT_MODE
|
||||
if (!isCipherReady){
|
||||
prepareCipher()
|
||||
}
|
||||
dataToProcess = cipherText
|
||||
val gcmSpec = GCMParameterSpec(GCM_TAG_LEN, iv)
|
||||
try {
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, gcmSpec)
|
||||
biometricPrompt.authenticate(biometricPromptInfo, BiometricPrompt.CryptoObject(cipher))
|
||||
} catch (e: KeyPermanentlyInvalidatedException){
|
||||
alertKeyPermanentlyInvalidatedException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetHashStorage() {
|
||||
keyStore.deleteEntry(KEY_ALIAS)
|
||||
volumeDatabase.getVolumes().forEach { volume ->
|
||||
volumeDatabase.removeHash(volume)
|
||||
}
|
||||
isCipherReady = false
|
||||
Toast.makeText(this, R.string.hash_storage_reset, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
protected fun loadVolumePath(callback: () -> Unit){
|
||||
currentVolumeName = if (switchHiddenVolume.isChecked){
|
||||
editVolumeName.text.toString()
|
||||
} else {
|
||||
editVolumePath.text.toString()
|
||||
}
|
||||
if (currentVolumeName.isEmpty()) {
|
||||
Toast.makeText(this, if (switchHiddenVolume.isChecked) {R.string.enter_volume_name} else {R.string.enter_volume_path}, Toast.LENGTH_SHORT).show()
|
||||
} else if (switchHiddenVolume.isChecked && currentVolumeName.contains("/")){
|
||||
Toast.makeText(this, R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
currentVolumePath = if (switchHiddenVolume.isChecked) {
|
||||
PathUtils.pathJoin(filesDir.path, currentVolumeName)
|
||||
} else {
|
||||
currentVolumeName
|
||||
}
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
fun errorDirectoryNotWritable(errorMsg: Int) {
|
||||
val dialog = ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
if (PathUtils.isPathOnExternalStorage(currentVolumePath, this)) {
|
||||
dialog.setView(
|
||||
layoutInflater.inflate(R.layout.dialog_sdcard_error, null).apply {
|
||||
findViewById<TextView>(R.id.path).text = PathUtils.getPackageDataFolder(this@VolumeActionActivity)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
dialog.setMessage(errorMsg)
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
}
|
111
app/src/main/java/sushi/hardcore/droidfs/VolumeData.kt
Normal file
111
app/src/main/java/sushi/hardcore/droidfs/VolumeData.kt
Normal file
@ -0,0 +1,111 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import sushi.hardcore.droidfs.filesystems.CryfsVolume
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.UUID
|
||||
|
||||
class VolumeData(
|
||||
val uuid: String,
|
||||
val name: String,
|
||||
val isHidden: Boolean = false,
|
||||
val type: Byte,
|
||||
var encryptedHash: ByteArray? = null,
|
||||
var iv: ByteArray? = null
|
||||
) : Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte(),
|
||||
parcel.createByteArray(),
|
||||
parcel.createByteArray()
|
||||
)
|
||||
|
||||
val shortName: String by lazy {
|
||||
File(name).name
|
||||
}
|
||||
|
||||
fun getFullPath(filesDir: String) = getFullPath(name, isHidden, filesDir)
|
||||
|
||||
fun canRead(filesDir: String): Boolean {
|
||||
val volumePath = getFullPath(filesDir)
|
||||
if (!File(volumePath).canRead()) {
|
||||
return false
|
||||
}
|
||||
val configFile = when (type) {
|
||||
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE -> PathUtils.pathJoin(volumePath, GocryptfsVolume.CONFIG_FILE_NAME)
|
||||
EncryptedVolume.CRYFS_VOLUME_TYPE -> PathUtils.pathJoin(volumePath, CryfsVolume.CONFIG_FILE_NAME)
|
||||
else -> return false
|
||||
}
|
||||
var success = true
|
||||
try {
|
||||
with (FileInputStream(configFile)) {
|
||||
read()
|
||||
close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
success = false
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
fun canWrite(filesDir: String): Boolean {
|
||||
return File(getFullPath(filesDir)).canWrite()
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
with (dest) {
|
||||
writeString(uuid)
|
||||
writeString(name)
|
||||
writeByte(if (isHidden) 1 else 0)
|
||||
writeByte(type)
|
||||
writeByteArray(encryptedHash)
|
||||
writeByteArray(iv)
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other !is VolumeData) {
|
||||
return false
|
||||
}
|
||||
return other.uuid == uuid || (other.name == name && other.isHidden == isHidden)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return name.hashCode()+isHidden.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VOLUMES_DIRECTORY = "volumes"
|
||||
|
||||
@JvmField
|
||||
val CREATOR = object : Parcelable.Creator<VolumeData> {
|
||||
override fun createFromParcel(parcel: Parcel) = VolumeData(parcel)
|
||||
override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size)
|
||||
}
|
||||
|
||||
fun newUuid(): String = UUID.randomUUID().toString()
|
||||
|
||||
fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
|
||||
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name)
|
||||
}
|
||||
|
||||
fun getFullPath(name: String, isHidden: Boolean, filesDir: String): String {
|
||||
return if (isHidden)
|
||||
getHiddenVolumeFullPath(filesDir, name)
|
||||
else
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
@ -2,71 +2,223 @@ package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.util.Log
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import java.io.File
|
||||
|
||||
class VolumeDatabase(context: Context): SQLiteOpenHelper(context,
|
||||
ConstValues.volumeDatabaseName, null, 3) {
|
||||
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 6) {
|
||||
companion object {
|
||||
const val TABLE_NAME = "Volumes"
|
||||
const val COLUMN_NAME = "name"
|
||||
const val COLUMN_HIDDEN = "hidden"
|
||||
const val COLUMN_HASH = "hash"
|
||||
const val COLUMN_IV = "iv"
|
||||
private const val TAG = "VolumeDatabase"
|
||||
private const val TABLE_NAME = "Volumes"
|
||||
private const val COLUMN_UUID = "uuid"
|
||||
private const val COLUMN_NAME = "name"
|
||||
private const val COLUMN_HIDDEN = "hidden"
|
||||
private const val COLUMN_TYPE = "type"
|
||||
private const val COLUMN_HASH = "hash"
|
||||
private const val COLUMN_IV = "iv"
|
||||
}
|
||||
|
||||
private fun contentValuesFromVolume(volume: Volume): ContentValues {
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(COLUMN_NAME, volume.name)
|
||||
contentValues.put(COLUMN_HIDDEN, volume.isHidden)
|
||||
contentValues.put(COLUMN_HASH, volume.hash)
|
||||
contentValues.put(COLUMN_IV, volume.iv)
|
||||
return contentValues
|
||||
private fun createTable(db: SQLiteDatabase) =
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
|
||||
"$COLUMN_UUID TEXT PRIMARY KEY," +
|
||||
"$COLUMN_NAME TEXT," +
|
||||
"$COLUMN_HIDDEN SHORT," +
|
||||
"$COLUMN_TYPE BLOB," +
|
||||
"$COLUMN_HASH BLOB," +
|
||||
"$COLUMN_IV BLOB" +
|
||||
");"
|
||||
)
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
createTable(db)
|
||||
File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()
|
||||
}
|
||||
|
||||
override fun onOpen(db: SQLiteDatabase) {
|
||||
//check if database has been corrupted by v2.1.1
|
||||
val cursor = db.rawQuery("SELECT * FROM $TABLE_NAME WHERE $COLUMN_TYPE IS NULL;", null)
|
||||
if (cursor.count > 0) {
|
||||
Log.w(TAG, "Found ${cursor.count} corrupted volumes")
|
||||
while (cursor.moveToNext()) {
|
||||
// fix columns left shift
|
||||
val uuid = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)+5)
|
||||
val name = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)-1)
|
||||
val isHidden = cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)-1) == 1.toShort()
|
||||
val type = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE)-1)[0]
|
||||
val hash = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)-1)
|
||||
val iv = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_IV)-1)
|
||||
if (db.delete(TABLE_NAME, "$COLUMN_IV=?", arrayOf(uuid)) < 1) {
|
||||
Log.e(TAG, "Failed to remove volume $name")
|
||||
}
|
||||
if (db.insert(TABLE_NAME, null, ContentValues().apply {
|
||||
put(COLUMN_UUID, uuid)
|
||||
put(COLUMN_NAME, name)
|
||||
put(COLUMN_HIDDEN, isHidden)
|
||||
put(COLUMN_TYPE, byteArrayOf(type))
|
||||
put(COLUMN_HASH, hash)
|
||||
put(COLUMN_IV, iv)
|
||||
}) < 0) {
|
||||
Log.e(TAG, "Failed to insert volume $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
|
||||
private fun getNewVolumePath(volumeName: String): File {
|
||||
return File(
|
||||
VolumeData.getFullPath(volumeName, true, context.filesDir.path)
|
||||
).canonicalFile
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
if (oldVersion == 3) {
|
||||
// Adding type column and set it to GOCRYPTFS_VOLUME_TYPE for all existing volumes
|
||||
db.execSQL("ALTER TABLE $TABLE_NAME ADD COLUMN $COLUMN_TYPE BLOB;")
|
||||
db.update(TABLE_NAME, ContentValues().apply {
|
||||
put(COLUMN_TYPE, byteArrayOf(EncryptedVolume.GOCRYPTFS_VOLUME_TYPE))
|
||||
}, null, null)
|
||||
|
||||
// Moving registered hidden volumes to the "volumes" directory
|
||||
if (File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()) {
|
||||
val cursor = db.query(
|
||||
TABLE_NAME,
|
||||
arrayOf(COLUMN_NAME),
|
||||
"$COLUMN_HIDDEN=?",
|
||||
arrayOf("1"),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
while (cursor.moveToNext()) {
|
||||
val volumeName = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME))
|
||||
val success = File(
|
||||
PathUtils.pathJoin(
|
||||
context.filesDir.path,
|
||||
volumeName
|
||||
)
|
||||
).renameTo(getNewVolumePath(volumeName))
|
||||
if (!success) {
|
||||
Log.e(TAG, "Failed to move $volumeName")
|
||||
}
|
||||
}
|
||||
cursor.close()
|
||||
} else {
|
||||
Log.e(TAG, "Volumes directory creation failed while upgrading")
|
||||
}
|
||||
}
|
||||
// Moving unregistered hidden volumes to the "volumes" directory
|
||||
File(context.filesDir.path).listFiles()?.let {
|
||||
for (i in it) {
|
||||
if (i.isDirectory && i.name != Constants.CRYFS_LOCAL_STATE_DIR && i.name != VolumeData.VOLUMES_DIRECTORY) {
|
||||
if (EncryptedVolume.getVolumeType(i.path) != (-1).toByte()) {
|
||||
if (!i.renameTo(getNewVolumePath(i.name))) {
|
||||
Log.e(TAG, "Failed to move "+i.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
val cursor = db.rawQuery("SELECT $COLUMN_NAME FROM $TABLE_NAME;", null)
|
||||
val volumeNames = arrayOfNulls<String>(cursor.count)
|
||||
var i = 0
|
||||
while (cursor.moveToNext()) {
|
||||
volumeNames[i++] = cursor.getString(0)
|
||||
}
|
||||
cursor.close()
|
||||
if (volumeNames.isEmpty()) {
|
||||
db.execSQL("DROP TABLE $TABLE_NAME;")
|
||||
createTable(db)
|
||||
} else {
|
||||
db.execSQL("ALTER TABLE $TABLE_NAME RENAME TO OLD;")
|
||||
createTable(db)
|
||||
val uuidsValues = volumeNames.indices.joinToString(", ") { "('${VolumeData.newUuid()}', ?)" }
|
||||
// add uuids to old data
|
||||
db.execSQL(
|
||||
"INSERT INTO $TABLE_NAME " +
|
||||
"WITH uuids($COLUMN_UUID, $COLUMN_NAME) AS (VALUES $uuidsValues) " +
|
||||
"SELECT $COLUMN_UUID, OLD.$COLUMN_NAME, $COLUMN_HIDDEN, $COLUMN_TYPE, $COLUMN_HASH, $COLUMN_IV " +
|
||||
"FROM OLD JOIN uuids ON OLD.name = uuids.name;",
|
||||
volumeNames
|
||||
)
|
||||
db.execSQL("DROP TABLE OLD;")
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS $TABLE_NAME ($COLUMN_NAME TEXT PRIMARY KEY, $COLUMN_HIDDEN SHORT, $COLUMN_HASH BLOB, $COLUMN_IV BLOB);"
|
||||
|
||||
private fun extractVolumeData(cursor: Cursor): VolumeData {
|
||||
return VolumeData(
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)),
|
||||
cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(),
|
||||
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE))[0],
|
||||
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)),
|
||||
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_IV))
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {}
|
||||
private fun getVolumeCursor(volumeName: String, isHidden: Boolean): Cursor {
|
||||
return readableDatabase.query(
|
||||
TABLE_NAME, null,
|
||||
"$COLUMN_NAME=? AND $COLUMN_HIDDEN=?",
|
||||
arrayOf(volumeName, (if (isHidden) 1 else 0).toString()),
|
||||
null, null, null
|
||||
)
|
||||
}
|
||||
|
||||
fun isVolumeSaved(volumeName: String): Boolean {
|
||||
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null)
|
||||
fun getVolume(volumeName: String, isHidden: Boolean): VolumeData? {
|
||||
val cursor = getVolumeCursor(volumeName, isHidden)
|
||||
val volumeData = if (cursor.moveToNext()) {
|
||||
extractVolumeData(cursor)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
cursor.close()
|
||||
return volumeData
|
||||
}
|
||||
|
||||
fun isVolumeSaved(volumeName: String, isHidden: Boolean): Boolean {
|
||||
val cursor = getVolumeCursor(volumeName, isHidden)
|
||||
val result = cursor.count > 0
|
||||
cursor.close()
|
||||
return result
|
||||
}
|
||||
|
||||
fun saveVolume(volume: Volume): Boolean {
|
||||
if (!isVolumeSaved(volume.name)){
|
||||
return (writableDatabase.insert(TABLE_NAME, null, contentValuesFromVolume(volume)) == 0.toLong())
|
||||
fun saveVolume(volume: VolumeData): Boolean {
|
||||
if (!isVolumeSaved(volume.name, volume.isHidden)) {
|
||||
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
|
||||
}
|
||||
|
||||
fun getVolumes(): List<Volume> {
|
||||
val list: MutableList<Volume> = ArrayList()
|
||||
fun getVolumes(): List<VolumeData> {
|
||||
val list: MutableList<VolumeData> = ArrayList()
|
||||
val cursor = readableDatabase.rawQuery("SELECT * FROM $TABLE_NAME", null)
|
||||
while (cursor.moveToNext()){
|
||||
list.add(
|
||||
Volume(
|
||||
cursor.getString(cursor.getColumnIndex(COLUMN_NAME)),
|
||||
cursor.getShort(cursor.getColumnIndex(COLUMN_HIDDEN)) == 1.toShort(),
|
||||
cursor.getBlob(cursor.getColumnIndex(COLUMN_HASH)),
|
||||
cursor.getBlob(cursor.getColumnIndex(COLUMN_IV))
|
||||
)
|
||||
)
|
||||
list.add(extractVolumeData(cursor))
|
||||
}
|
||||
cursor.close()
|
||||
return list
|
||||
}
|
||||
|
||||
fun isHashSaved(volumeName: String): Boolean {
|
||||
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME, COLUMN_HASH), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null)
|
||||
fun isHashSaved(volume: VolumeData): Boolean {
|
||||
val cursor = readableDatabase.rawQuery("SELECT $COLUMN_HASH FROM $TABLE_NAME WHERE $COLUMN_UUID=?", arrayOf(volume.uuid))
|
||||
var isHashSaved = false
|
||||
if (cursor.moveToNext()){
|
||||
if (cursor.getBlob(cursor.getColumnIndex(COLUMN_HASH)) != null){
|
||||
if (cursor.moveToNext()) {
|
||||
if (cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)) != null) {
|
||||
isHashSaved = true
|
||||
}
|
||||
}
|
||||
@ -74,23 +226,34 @@ class VolumeDatabase(context: Context): SQLiteOpenHelper(context,
|
||||
return isHashSaved
|
||||
}
|
||||
|
||||
fun addHash(volume: Volume): Boolean {
|
||||
return writableDatabase.update(TABLE_NAME, contentValuesFromVolume(volume), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
|
||||
fun addHash(volume: VolumeData): Boolean {
|
||||
return writableDatabase.update(TABLE_NAME, ContentValues().apply {
|
||||
put(COLUMN_HASH, volume.encryptedHash)
|
||||
put(COLUMN_IV, volume.iv)
|
||||
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
|
||||
}
|
||||
|
||||
fun removeHash(volume: Volume): Boolean {
|
||||
fun removeHash(volume: VolumeData): Boolean {
|
||||
return writableDatabase.update(
|
||||
TABLE_NAME, contentValuesFromVolume(
|
||||
Volume(
|
||||
volume.name,
|
||||
volume.isHidden,
|
||||
null,
|
||||
null
|
||||
)
|
||||
), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
|
||||
TABLE_NAME,
|
||||
ContentValues().apply {
|
||||
put(COLUMN_HASH, null as ByteArray?)
|
||||
put(COLUMN_IV, null as ByteArray?)
|
||||
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)
|
||||
) > 0
|
||||
}
|
||||
|
||||
fun removeVolume(volume: Volume): Boolean {
|
||||
return writableDatabase.delete(TABLE_NAME, "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
|
||||
fun renameVolume(volume: VolumeData, newName: String): Boolean {
|
||||
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
|
||||
}
|
||||
}
|
75
app/src/main/java/sushi/hardcore/droidfs/VolumeManager.kt
Normal file
75
app/src/main/java/sushi/hardcore/droidfs/VolumeManager.kt
Normal file
@ -0,0 +1,75 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import sushi.hardcore.droidfs.content_providers.VolumeProvider
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.Observable
|
||||
|
||||
class VolumeManager(private val context: Context): Observable<VolumeManager.Observer>() {
|
||||
interface Observer {
|
||||
fun onVolumeStateChanged(volume: VolumeData) {}
|
||||
fun onAllVolumesClosed() {}
|
||||
}
|
||||
|
||||
private var id = 0
|
||||
private val volumes = HashMap<Int, EncryptedVolume>()
|
||||
private val volumesData = HashMap<VolumeData, Int>()
|
||||
private val scopes = HashMap<Int, CoroutineScope>()
|
||||
|
||||
fun insert(volume: EncryptedVolume, data: VolumeData): Int {
|
||||
volumes[id] = volume
|
||||
volumesData[data] = id
|
||||
observers.forEach { it.onVolumeStateChanged(data) }
|
||||
VolumeProvider.notifyRootsChanged(context)
|
||||
return id++
|
||||
}
|
||||
|
||||
fun isOpen(volume: VolumeData): Boolean {
|
||||
return volumesData.containsKey(volume)
|
||||
}
|
||||
|
||||
fun getVolumeId(volume: VolumeData): Int? {
|
||||
return volumesData[volume]
|
||||
}
|
||||
|
||||
fun getVolume(id: Int): EncryptedVolume? {
|
||||
return volumes[id]
|
||||
}
|
||||
|
||||
fun listVolumes(): List<Pair<Int, VolumeData>> {
|
||||
return volumesData.map { (data, id) -> Pair(id, data) }
|
||||
}
|
||||
|
||||
fun getVolumeCount() = volumes.size
|
||||
|
||||
fun getCoroutineScope(volumeId: Int): CoroutineScope {
|
||||
return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it }
|
||||
}
|
||||
|
||||
fun closeVolume(id: Int) {
|
||||
volumes.remove(id)?.let { volume ->
|
||||
scopes[id]?.cancel()
|
||||
volume.closeVolume()
|
||||
volumesData.filter { it.value == id }.forEach { entry ->
|
||||
volumesData.remove(entry.key)
|
||||
observers.forEach { it.onVolumeStateChanged(entry.key) }
|
||||
}
|
||||
VolumeProvider.notifyRootsChanged(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeAll() {
|
||||
volumes.forEach {
|
||||
scopes[it.key]?.cancel()
|
||||
it.value.closeVolume()
|
||||
}
|
||||
volumes.clear()
|
||||
volumesData.clear()
|
||||
observers.forEach { it.onAllVolumesClosed() }
|
||||
VolumeProvider.notifyRootsChanged(context)
|
||||
}
|
||||
}
|
102
app/src/main/java/sushi/hardcore/droidfs/VolumeManagerApp.kt
Normal file
102
app/src/main/java/sushi/hardcore/droidfs/VolumeManagerApp.kt
Normal file
@ -0,0 +1,102 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
|
||||
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||
|
||||
class VolumeManagerApp : Application(), DefaultLifecycleObserver {
|
||||
companion object {
|
||||
const val ACTION_CLOSE_ALL_VOLUMES = "close_all"
|
||||
}
|
||||
|
||||
private val closingServiceIntent by lazy {
|
||||
Intent(this, ClosingService::class.java)
|
||||
}
|
||||
private val keepAliveServiceStartIntent by lazy {
|
||||
Intent(this, KeepAliveService::class.java).apply {
|
||||
action = KeepAliveService.ACTION_START
|
||||
}.putExtra(
|
||||
"notification", KeepAliveService.NotificationDetails(
|
||||
"KeepAlive",
|
||||
getString(R.string.keep_alive_notification_title),
|
||||
getString(R.string.keep_alive_notification_text),
|
||||
KeepAliveService.NotificationDetails.NotificationAction(
|
||||
R.drawable.icon_lock,
|
||||
getString(R.string.close_all),
|
||||
ACTION_CLOSE_ALL_VOLUMES,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
private val usfBackgroundDelegate = AndroidUtils.LiveBooleanPreference("usf_background", false) { _ ->
|
||||
updateServicesStates()
|
||||
}
|
||||
private val usfBackground by usfBackgroundDelegate
|
||||
private val usfKeepOpenDelegate = AndroidUtils.LiveBooleanPreference("usf_keep_open", false) { _ ->
|
||||
updateServicesStates()
|
||||
}
|
||||
private val usfKeepOpen by usfKeepOpenDelegate
|
||||
var isExporting = false
|
||||
var isStartingExternalApp = false
|
||||
val volumeManager = VolumeManager(this).also {
|
||||
it.observe(object : VolumeManager.Observer {
|
||||
override fun onVolumeStateChanged(volume: VolumeData) {
|
||||
updateServicesStates()
|
||||
}
|
||||
|
||||
override fun onAllVolumesClosed() {
|
||||
stopKeepAliveService()
|
||||
// closingService should not be running when this callback is triggered
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super<Application>.onCreate()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
AndroidUtils.LiveBooleanPreference.init(this, usfBackgroundDelegate, usfKeepOpenDelegate)
|
||||
}
|
||||
|
||||
fun updateServicesStates() {
|
||||
if (usfBackground && volumeManager.getVolumeCount() > 0) {
|
||||
if (usfKeepOpen) {
|
||||
stopService(closingServiceIntent)
|
||||
if (!AndroidUtils.isServiceRunning(this@VolumeManagerApp, KeepAliveService::class.java)) {
|
||||
ContextCompat.startForegroundService(this, keepAliveServiceStartIntent)
|
||||
}
|
||||
} else {
|
||||
stopKeepAliveService()
|
||||
if (!AndroidUtils.isServiceRunning(this@VolumeManagerApp, ClosingService::class.java)) {
|
||||
startService(closingServiceIntent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopService(closingServiceIntent)
|
||||
stopKeepAliveService()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopKeepAliveService() {
|
||||
stopService(Intent(this, KeepAliveService::class.java))
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
isStartingExternalApp = false
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (!isStartingExternalApp) {
|
||||
if (!usfBackground) {
|
||||
volumeManager.closeAll()
|
||||
}
|
||||
if (!usfBackground || !isExporting) {
|
||||
TemporaryFileProvider.instance.wipe()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
227
app/src/main/java/sushi/hardcore/droidfs/VolumeOpener.kt
Normal file
227
app/src/main/java/sushi/hardcore/droidfs/VolumeOpener.kt
Normal file
@ -0,0 +1,227 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.text.InputType
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.PreferenceManager
|
||||
import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
|
||||
import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.UIUtils
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.util.*
|
||||
|
||||
class VolumeOpener(
|
||||
private val activity: FragmentActivity,
|
||||
) {
|
||||
interface VolumeOpenerCallbacks {
|
||||
fun onHashStorageReset() {}
|
||||
fun onVolumeOpened(id: Int)
|
||||
}
|
||||
|
||||
private val volumeDatabase = VolumeDatabase(activity)
|
||||
private var fingerprintProtector: FingerprintProtector? = null
|
||||
private val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
private val theme = (activity as BaseActivity).theme
|
||||
var defaultVolumeName: String? = sharedPrefs.getString(DEFAULT_VOLUME_KEY, null)
|
||||
private var dialogBinding: DialogOpenVolumeBinding? = null
|
||||
private val volumeManager = (activity.application as VolumeManagerApp).volumeManager
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
fingerprintProtector = FingerprintProtector.new(activity, theme, volumeDatabase)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getErrorMsg(result: EncryptedVolume.InitResult): String {
|
||||
return if (result.errorStringId == 0) {
|
||||
activity.getString(R.string.unknown_error_code, result.errorCode)
|
||||
} else {
|
||||
activity.getString(result.errorStringId)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23
|
||||
fun openVolume(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks) {
|
||||
val volumeId = volumeManager.getVolumeId(volume)
|
||||
if (volumeId == null) {
|
||||
if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE && BuildConfig.GOCRYPTFS_DISABLED) {
|
||||
Toast.makeText(activity, R.string.gocryptfs_disabled, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
} else if (volume.type == EncryptedVolume.CRYFS_VOLUME_TYPE && BuildConfig.CRYFS_DISABLED) {
|
||||
Toast.makeText(activity, R.string.cryfs_disabled, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
var askForPassword = true
|
||||
fingerprintProtector?.let { fingerprintProtector ->
|
||||
volume.encryptedHash?.let { encryptedHash ->
|
||||
volume.iv?.let { iv ->
|
||||
askForPassword = false
|
||||
fingerprintProtector.listener = object : FingerprintProtector.Listener {
|
||||
override fun onHashStorageReset() {
|
||||
callbacks.onHashStorageReset()
|
||||
}
|
||||
override fun onPasswordHashDecrypted(hash: ByteArray) {
|
||||
object : LoadingTask<EncryptedVolume.InitResult>(activity, theme, R.string.loading_msg_open) {
|
||||
override suspend fun doTask(): EncryptedVolume.InitResult {
|
||||
val result = EncryptedVolume.init(volume, activity.filesDir.path, null, hash, null)
|
||||
Arrays.fill(hash, 0)
|
||||
return result
|
||||
}
|
||||
}.startTask(activity.lifecycleScope) { result ->
|
||||
val encryptedVolume = result.volume
|
||||
if (encryptedVolume == null) {
|
||||
CustomAlertDialogBuilder(activity, theme)
|
||||
.setTitle(R.string.open_volume_failed)
|
||||
.setMessage(getErrorMsg(result))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
callbacks.onVolumeOpened(volumeManager.insert(encryptedVolume, volume))
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onPasswordHashSaved() {}
|
||||
override fun onFailed(pending: Boolean) {
|
||||
if (!pending && sharedPrefs.getBoolean("passwordFallback", true)) {
|
||||
askForPassword(volume, isVolumeSaved, callbacks)
|
||||
}
|
||||
}
|
||||
}
|
||||
fingerprintProtector.loadPasswordHash(volume.shortName, encryptedHash, iv)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (askForPassword) {
|
||||
askForPassword(volume, isVolumeSaved, callbacks)
|
||||
}
|
||||
} else {
|
||||
callbacks.onVolumeOpened(volumeId)
|
||||
}
|
||||
}
|
||||
|
||||
fun wipeSensitive() {
|
||||
dialogBinding?.editPassword?.text?.clear()
|
||||
}
|
||||
|
||||
private fun onPasswordSubmitted(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks) {
|
||||
if (dialogBinding!!.checkboxDefaultOpen.isChecked xor (defaultVolumeName == volume.name)) {
|
||||
with (sharedPrefs.edit()) {
|
||||
defaultVolumeName = if (dialogBinding!!.checkboxDefaultOpen.isChecked) {
|
||||
putString(DEFAULT_VOLUME_KEY, volume.name)
|
||||
volume.name
|
||||
} else {
|
||||
remove(DEFAULT_VOLUME_KEY)
|
||||
null
|
||||
}
|
||||
apply()
|
||||
}
|
||||
}
|
||||
val password = UIUtils.encodeEditTextContent(dialogBinding!!.editPassword)
|
||||
val savePasswordHash = dialogBinding!!.checkboxSavePassword.isChecked
|
||||
dialogBinding = null
|
||||
// openVolumeWithPassword is responsible for wiping the password
|
||||
openVolumeWithPassword(
|
||||
volume,
|
||||
password,
|
||||
isVolumeSaved,
|
||||
savePasswordHash,
|
||||
callbacks,
|
||||
)
|
||||
}
|
||||
|
||||
private fun askForPassword(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks, savePasswordHash: Boolean = false) {
|
||||
dialogBinding = DialogOpenVolumeBinding.inflate(activity.layoutInflater)
|
||||
if (isVolumeSaved) {
|
||||
if (!sharedPrefs.getBoolean("usf_fingerprint", false) || fingerprintProtector == null || volume.encryptedHash != null) {
|
||||
dialogBinding!!.checkboxSavePassword.visibility = View.GONE
|
||||
} else {
|
||||
dialogBinding!!.checkboxSavePassword.isChecked = savePasswordHash
|
||||
}
|
||||
dialogBinding!!.checkboxDefaultOpen.isChecked = defaultVolumeName == volume.name
|
||||
} else {
|
||||
dialogBinding!!.checkboxSavePassword.visibility = View.GONE
|
||||
dialogBinding!!.checkboxDefaultOpen.visibility = View.GONE
|
||||
}
|
||||
val dialog = CustomAlertDialogBuilder(activity, theme)
|
||||
.setTitle(activity.getString(R.string.open_dialog_title, volume.shortName))
|
||||
.setView(dialogBinding!!.root)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.open) { _, _ ->
|
||||
onPasswordSubmitted(volume, isVolumeSaved, callbacks)
|
||||
}
|
||||
.create()
|
||||
dialogBinding!!.editPassword.apply {
|
||||
setOnEditorActionListener { _, _, _ ->
|
||||
dialog.dismiss()
|
||||
onPasswordSubmitted(volume, isVolumeSaved, callbacks)
|
||||
true
|
||||
}
|
||||
if (sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false)) {
|
||||
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun openVolumeWithPassword(volume: VolumeData, password: ByteArray, isVolumeSaved: Boolean, savePasswordHash: Boolean, callbacks: VolumeOpenerCallbacks) {
|
||||
val returnedHash: ObjRef<ByteArray?>? = if (savePasswordHash) {
|
||||
ObjRef(null)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
object : LoadingTask<EncryptedVolume.InitResult>(activity, theme, R.string.loading_msg_open) {
|
||||
override suspend fun doTask(): EncryptedVolume.InitResult {
|
||||
val result = EncryptedVolume.init(volume, activity.filesDir.path, password, null, returnedHash)
|
||||
Arrays.fill(password, 0)
|
||||
return result
|
||||
}
|
||||
}.startTask(activity.lifecycleScope) { result ->
|
||||
val encryptedVolume = result.volume
|
||||
if (encryptedVolume == null) {
|
||||
CustomAlertDialogBuilder(activity, theme)
|
||||
.setTitle(R.string.open_volume_failed)
|
||||
.setMessage(getErrorMsg(result))
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
if (result.worthRetry) {
|
||||
askForPassword(volume, isVolumeSaved, callbacks, savePasswordHash)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
val fingerprintProtector = fingerprintProtector
|
||||
@SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23
|
||||
if (savePasswordHash && returnedHash != null && fingerprintProtector != null) {
|
||||
fingerprintProtector.listener = object : FingerprintProtector.Listener {
|
||||
override fun onHashStorageReset() {
|
||||
callbacks.onHashStorageReset()
|
||||
}
|
||||
override fun onPasswordHashDecrypted(hash: ByteArray) {}
|
||||
override fun onPasswordHashSaved() {
|
||||
Arrays.fill(returnedHash.value!!, 0)
|
||||
callbacks.onVolumeOpened(volumeManager.insert(encryptedVolume, volume))
|
||||
}
|
||||
private var isClosed = false
|
||||
override fun onFailed(pending: Boolean) {
|
||||
if (!isClosed) {
|
||||
encryptedVolume.closeVolume()
|
||||
isClosed = true
|
||||
}
|
||||
Arrays.fill(returnedHash.value!!, 0)
|
||||
}
|
||||
}
|
||||
fingerprintProtector.savePasswordHash(volume, returnedHash.value!!)
|
||||
} else {
|
||||
callbacks.onVolumeOpened(volumeManager.insert(encryptedVolume, volume))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
package sushi.hardcore.droidfs.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.CheckedTextView
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.widgets.ThemeColor
|
||||
|
||||
class DialogSingleChoiceAdapter(private val context: Context, private val entries: List<String>): BaseAdapter() {
|
||||
private val inflater: LayoutInflater = LayoutInflater.from(context)
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
|
||||
val view: View = convertView ?: inflater.inflate(R.layout.adapter_colored_dialog_single_choice, parent, false)
|
||||
val checkedTextView = view.findViewById<CheckedTextView>(android.R.id.text1)
|
||||
checkedTextView.text = getItem(position)
|
||||
val typedArray = context.theme.obtainStyledAttributes(arrayOf(android.R.attr.listChoiceIndicatorSingle).toIntArray())
|
||||
val drawable = typedArray.getDrawable(0)
|
||||
typedArray.recycle()
|
||||
drawable?.setTint(ThemeColor.getThemeColor(context))
|
||||
checkedTextView.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null)
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): String {
|
||||
return entries[position]
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long { return 0 }
|
||||
|
||||
override fun getCount(): Int { return entries.size }
|
||||
}
|
@ -1,119 +1,222 @@
|
||||
package sushi.hardcore.droidfs.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.view.LayoutInflater
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Bitmap
|
||||
import android.util.LruCache
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import sushi.hardcore.droidfs.ConstValues.Companion.getAssociatedDrawable
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.*
|
||||
import sushi.hardcore.droidfs.FileTypes
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.ThumbnailsLoader
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.filesystems.Stat
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.widgets.ThemeColor
|
||||
import java.text.DateFormat
|
||||
import java.util.*
|
||||
|
||||
class ExplorerElementAdapter(private val context: Context) : BaseAdapter() {
|
||||
private val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.getDefault())
|
||||
private var explorerElements = listOf<ExplorerElement>()
|
||||
private val inflater: LayoutInflater = LayoutInflater.from(context)
|
||||
val selectedItems: MutableList<Int> = ArrayList()
|
||||
private val themeColor = ThemeColor.getThemeColor(context)
|
||||
override fun getCount(): Int {
|
||||
return explorerElements.size
|
||||
class ExplorerElementAdapter(
|
||||
val activity: AppCompatActivity,
|
||||
val encryptedVolume: EncryptedVolume?,
|
||||
private val listener: Listener,
|
||||
thumbnailMaxSize: Long,
|
||||
) : SelectableAdapter<ExplorerElement>(listener::onSelectionChanged) {
|
||||
val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.getDefault())
|
||||
var explorerElements = listOf<ExplorerElement>()
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
set(value) {
|
||||
field = value
|
||||
thumbnailsCache?.evictAll()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
var isUsingListLayout = true
|
||||
private var thumbnailsLoader: ThumbnailsLoader? = null
|
||||
private var thumbnailsCache: LruCache<String, Bitmap>? = null
|
||||
var loadThumbnails = true
|
||||
|
||||
init {
|
||||
if (encryptedVolume != null) {
|
||||
thumbnailsLoader = ThumbnailsLoader(activity, encryptedVolume, thumbnailMaxSize, activity.lifecycleScope).apply {
|
||||
initialize()
|
||||
}
|
||||
thumbnailsCache = object : LruCache<String, Bitmap>((Runtime.getRuntime().maxMemory() / 4).toInt()) {
|
||||
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): ExplorerElement {
|
||||
return explorerElements[position]
|
||||
interface Listener {
|
||||
fun onSelectionChanged(size: Int)
|
||||
fun onExplorerElementClick(position: Int)
|
||||
fun onExplorerElementLongClick(position: Int)
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return 0
|
||||
override fun getItems(): List<ExplorerElement> {
|
||||
return explorerElements
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view: View = convertView ?: inflater.inflate(R.layout.adapter_explorer_element, parent, false)
|
||||
val currentElement = getItem(position)
|
||||
val textElementName = view.findViewById<TextView>(R.id.text_element_name)
|
||||
textElementName.text = currentElement.name
|
||||
val textElementMtime = view.findViewById<TextView>(R.id.text_element_mtime)
|
||||
val textElementSize = view.findViewById<TextView>(R.id.text_element_size)
|
||||
if (!currentElement.isParentFolder){
|
||||
textElementSize.text = PathUtils.formatSize(currentElement.size)
|
||||
override fun toggleSelection(position: Int): Boolean {
|
||||
return if (!explorerElements[position].isParentFolder) {
|
||||
super.toggleSelection(position)
|
||||
} else {
|
||||
textElementSize.text = ""
|
||||
false
|
||||
}
|
||||
var drawableId = R.drawable.icon_folder
|
||||
when {
|
||||
currentElement.isDirectory -> {
|
||||
textElementMtime.text = dateFormat.format(currentElement.mTime)
|
||||
}
|
||||
currentElement.isParentFolder -> {
|
||||
textElementMtime.setText(R.string.parent_folder)
|
||||
}
|
||||
else -> {
|
||||
textElementMtime.text = dateFormat.format(currentElement.mTime)
|
||||
drawableId = getAssociatedDrawable(currentElement.name)
|
||||
}
|
||||
}
|
||||
val elementIcon = view.findViewById<ImageView>(R.id.icon_element)
|
||||
val icon = ContextCompat.getDrawable(context, drawableId)
|
||||
icon?.colorFilter = PorterDuffColorFilter(themeColor, PorterDuff.Mode.SRC_IN)
|
||||
elementIcon.setImageDrawable(icon)
|
||||
if (selectedItems.contains(position)) {
|
||||
view.setBackgroundColor(ContextCompat.getColor(context, R.color.item_selected))
|
||||
} else {
|
||||
view.setBackgroundColor(Color.alpha(0))
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
fun onItemClick(position: Int) {
|
||||
if (selectedItems.isNotEmpty()) {
|
||||
if (!explorerElements[position].isParentFolder) {
|
||||
if (selectedItems.contains(position)) {
|
||||
selectedItems.remove(position)
|
||||
} else {
|
||||
selectedItems.add(position)
|
||||
override fun onItemClick(position: Int): Boolean {
|
||||
listener.onExplorerElementClick(position)
|
||||
return super.onItemClick(position)
|
||||
}
|
||||
|
||||
override fun onItemLongClick(position: Int): Boolean {
|
||||
listener.onExplorerElementLongClick(position)
|
||||
return super.onItemLongClick(position)
|
||||
}
|
||||
|
||||
override fun isSelectable(position: Int): Boolean {
|
||||
return !explorerElements[position].isParentFolder
|
||||
}
|
||||
|
||||
open class ExplorerElementViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val textElementName by lazy {
|
||||
itemView.findViewById<TextView>(R.id.text_element_name)
|
||||
}
|
||||
protected val textElementSize: TextView by lazy {
|
||||
itemView.findViewById(R.id.text_element_size)
|
||||
}
|
||||
protected val textElementMtime: TextView by lazy {
|
||||
itemView.findViewById(R.id.text_element_mtime)
|
||||
}
|
||||
protected val icon: ImageView by lazy {
|
||||
itemView.findViewById(R.id.icon_element)
|
||||
}
|
||||
private val selectableContainer: LinearLayout by lazy {
|
||||
itemView.findViewById(R.id.selectable_container)
|
||||
}
|
||||
|
||||
open fun bind(explorerElement: ExplorerElement, position: Int) {
|
||||
textElementName.text = explorerElement.name
|
||||
(bindingAdapter as ExplorerElementAdapter?)?.setSelectable(selectableContainer, itemView, position)
|
||||
}
|
||||
}
|
||||
|
||||
open class RegularElementViewHolder(itemView: View) : ExplorerElementViewHolder(itemView) {
|
||||
open fun bind(explorerElement: ExplorerElement, position: Int, isSelected: Boolean) {
|
||||
super.bind(explorerElement, position)
|
||||
textElementSize.text = PathUtils.formatSize(explorerElement.stat.size)
|
||||
(bindingAdapter as ExplorerElementAdapter?)?.let {
|
||||
textElementMtime.text = it.dateFormat.format(explorerElement.stat.mTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FileViewHolder(itemView: View) : RegularElementViewHolder(itemView) {
|
||||
private var task = -1
|
||||
|
||||
fun cancelThumbnailLoading(adapter: ExplorerElementAdapter) {
|
||||
if (task != -1) {
|
||||
adapter.thumbnailsLoader?.cancel(task)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setThumbnailOrDefaultIcon(fullPath: String, defaultIconId: Int) {
|
||||
var setDefaultIcon = true
|
||||
(bindingAdapter as ExplorerElementAdapter?)?.let { adapter ->
|
||||
adapter.thumbnailsCache?.let {
|
||||
val thumbnail = it.get(fullPath)
|
||||
if (thumbnail != null) {
|
||||
icon.setImageBitmap(thumbnail)
|
||||
setDefaultIcon = false
|
||||
} else if (adapter.loadThumbnails) {
|
||||
task = adapter.thumbnailsLoader!!.loadAsync(fullPath, icon) { resource ->
|
||||
val bitmap = resource.toBitmap()
|
||||
adapter.thumbnailsCache!!.put(fullPath, bitmap.copy(bitmap.config, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
if (setDefaultIcon) {
|
||||
icon.setImageResource(defaultIconId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun bind(explorerElement: ExplorerElement, position: Int, isSelected: Boolean) {
|
||||
super.bind(explorerElement, position, isSelected)
|
||||
when {
|
||||
FileTypes.isImage(explorerElement.name) -> {
|
||||
setThumbnailOrDefaultIcon(explorerElement.fullPath, R.drawable.icon_file_image)
|
||||
}
|
||||
FileTypes.isVideo(explorerElement.name) -> {
|
||||
setThumbnailOrDefaultIcon(explorerElement.fullPath, R.drawable.icon_file_video)
|
||||
}
|
||||
else -> icon.setImageResource(
|
||||
when {
|
||||
FileTypes.isText(explorerElement.name) -> R.drawable.icon_file_text
|
||||
FileTypes.isPDF(explorerElement.name) -> R.drawable.icon_file_pdf
|
||||
FileTypes.isAudio(explorerElement.name) -> R.drawable.icon_file_audio
|
||||
else -> R.drawable.icon_file_unknown
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onItemLongClick(position: Int) {
|
||||
if (!explorerElements[position].isParentFolder) {
|
||||
if (!selectedItems.contains(position)) {
|
||||
selectedItems.add(position)
|
||||
class DirectoryViewHolder(itemView: View) : RegularElementViewHolder(itemView) {
|
||||
override fun bind(explorerElement: ExplorerElement, position: Int, isSelected: Boolean) {
|
||||
super.bind(explorerElement, position, isSelected)
|
||||
icon.setImageResource(R.drawable.icon_folder)
|
||||
}
|
||||
}
|
||||
|
||||
class ParentFolderViewHolder(itemView: View): ExplorerElementViewHolder(itemView) {
|
||||
override fun bind(explorerElement: ExplorerElement, position: Int) {
|
||||
super.bind(explorerElement, position)
|
||||
textElementSize.text = ""
|
||||
textElementMtime.setText(R.string.parent_folder)
|
||||
icon.setImageResource(R.drawable.icon_folder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
|
||||
if (holder is FileViewHolder) {
|
||||
holder.cancelThumbnailLoading(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val view = activity.layoutInflater.inflate(
|
||||
if (isUsingListLayout) {
|
||||
R.layout.adapter_explorer_element_list
|
||||
} else {
|
||||
selectedItems.remove(position)
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
R.layout.adapter_explorer_element_grid
|
||||
}, parent, false
|
||||
)
|
||||
return when (viewType) {
|
||||
Stat.S_IFREG -> FileViewHolder(view)
|
||||
Stat.S_IFDIR -> DirectoryViewHolder(view)
|
||||
Stat.PARENT_FOLDER_TYPE -> ParentFolderViewHolder(view)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
for (i in explorerElements.indices) {
|
||||
if (!selectedItems.contains(i) && !explorerElements[i].isParentFolder) {
|
||||
selectedItems.add(i)
|
||||
}
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val element = explorerElements[position]
|
||||
if (element.isParentFolder) {
|
||||
(holder as ParentFolderViewHolder).bind(element, position)
|
||||
} else {
|
||||
(holder as RegularElementViewHolder).bind(element, position, selectedItems.contains(position))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun unSelectAll() {
|
||||
selectedItems.clear()
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun setExplorerElements(explorer_elements: List<ExplorerElement>) {
|
||||
this.explorerElements = explorer_elements
|
||||
unSelectAll()
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return explorerElements[position].stat.type
|
||||
}
|
||||
}
|
@ -5,9 +5,10 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.widgets.ColoredImageView
|
||||
|
||||
open class IconTextDialogAdapter(private val context: Context): BaseAdapter() {
|
||||
private val inflater: LayoutInflater = LayoutInflater.from(context)
|
||||
@ -17,8 +18,8 @@ open class IconTextDialogAdapter(private val context: Context): BaseAdapter() {
|
||||
val view: View = convertView ?: inflater.inflate(R.layout.adapter_dialog_icon_text, parent, false)
|
||||
val text = view.findViewById<TextView>(R.id.text)
|
||||
text.text = context.getString(items[position][1] as Int)
|
||||
val icon = view.findViewById<ColoredImageView>(R.id.icon)
|
||||
icon.setImageDrawable(context.getDrawable(items[position][2] as Int))
|
||||
val icon = view.findViewById<ImageView>(R.id.icon)
|
||||
icon.setImageDrawable(ContextCompat.getDrawable(context, items[position][2] as Int))
|
||||
return view
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,7 @@ class OpenAsDialogAdapter(context: Context, showOpenWithExternalApp: Boolean) :
|
||||
listOf("image", R.string.image, R.drawable.icon_file_image),
|
||||
listOf("video", R.string.video, R.drawable.icon_file_video),
|
||||
listOf("audio", R.string.audio, R.drawable.icon_file_audio),
|
||||
listOf("pdf", R.string.pdf_document, R.drawable.icon_file_pdf),
|
||||
listOf("text", R.string.text, R.drawable.icon_file_text)
|
||||
)
|
||||
init {
|
||||
|
@ -1,111 +0,0 @@
|
||||
package sushi.hardcore.droidfs.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.Volume
|
||||
import sushi.hardcore.droidfs.VolumeDatabase
|
||||
import sushi.hardcore.droidfs.util.WidgetUtil
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.NonScrollableColoredBorderListView
|
||||
import java.io.File
|
||||
|
||||
class SavedVolumesAdapter(private val context: Context, private val volumeDatabase: VolumeDatabase) : BaseAdapter() {
|
||||
private val inflater: LayoutInflater = LayoutInflater.from(context)
|
||||
private lateinit var nonScrollableColoredBorderListView: NonScrollableColoredBorderListView
|
||||
|
||||
override fun getCount(): Int {
|
||||
return volumeDatabase.getVolumes().size
|
||||
}
|
||||
|
||||
override fun getItem(position: Int): Volume {
|
||||
return volumeDatabase.getVolumes()[position]
|
||||
}
|
||||
|
||||
override fun getItemId(position: Int): Long {
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun deletePasswordHash(volume: Volume){
|
||||
volumeDatabase.removeHash(volume)
|
||||
volume.hash = null
|
||||
volume.iv = null
|
||||
}
|
||||
|
||||
private fun deleteVolumeData(volume: Volume, parent: ViewGroup){
|
||||
volumeDatabase.removeVolume(volume)
|
||||
refresh(parent)
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
if (!::nonScrollableColoredBorderListView.isInitialized){
|
||||
nonScrollableColoredBorderListView = parent as NonScrollableColoredBorderListView
|
||||
}
|
||||
val view: View = convertView ?: inflater.inflate(R.layout.adapter_saved_volume, parent, false)
|
||||
val volumeNameTextView = view.findViewById<TextView>(R.id.volume_name_textview)
|
||||
val currentVolume = getItem(position)
|
||||
volumeNameTextView.text = currentVolume.name
|
||||
val deleteImageView = view.findViewById<ImageView>(R.id.delete_imageview)
|
||||
deleteImageView.setOnClickListener {
|
||||
val dialog = ColoredAlertDialogBuilder(context)
|
||||
dialog.setTitle(R.string.warning)
|
||||
if (currentVolume.isHidden){
|
||||
if (currentVolume.hash != null) {
|
||||
dialog.setMessage(R.string.hidden_volume_delete_question_hash)
|
||||
dialog.setPositiveButton(R.string.password_hash){ _, _ ->
|
||||
deletePasswordHash(currentVolume)
|
||||
}
|
||||
dialog.setNegativeButton(R.string.password_hash_and_path){ _, _ ->
|
||||
deleteVolumeData(currentVolume, parent)
|
||||
}
|
||||
dialog.setNeutralButton(R.string.whole_volume){ _, _ ->
|
||||
PathUtils.recursiveRemoveDirectory(File(PathUtils.pathJoin(context.filesDir.path, currentVolume.name)))
|
||||
deleteVolumeData(currentVolume, parent)
|
||||
}
|
||||
} else {
|
||||
dialog.setMessage(R.string.hidden_volume_delete_question)
|
||||
dialog.setPositiveButton(R.string.path_only){ _, _ ->
|
||||
deleteVolumeData(currentVolume, parent)
|
||||
}
|
||||
dialog.setNegativeButton(R.string.whole_volume){ _, _ ->
|
||||
PathUtils.recursiveRemoveDirectory(File(PathUtils.pathJoin(context.filesDir.path, currentVolume.name)))
|
||||
deleteVolumeData(currentVolume, parent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (currentVolume.hash != null) {
|
||||
dialog.setMessage(R.string.delete_hash_or_all)
|
||||
dialog.setNegativeButton(R.string.password_hash_and_path) { _, _ ->
|
||||
deleteVolumeData(currentVolume, parent)
|
||||
}
|
||||
dialog.setPositiveButton(R.string.password_hash) { _, _ ->
|
||||
deletePasswordHash(currentVolume)
|
||||
}
|
||||
} else {
|
||||
dialog.setMessage(R.string.ask_delete_volume_path)
|
||||
dialog.setPositiveButton(R.string.ok) {_, _ ->
|
||||
deleteVolumeData(currentVolume, parent)
|
||||
}
|
||||
dialog.setNegativeButton(R.string.cancel, null)
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
private fun refresh(parent: ViewGroup) {
|
||||
notifyDataSetChanged()
|
||||
if (count == 0){
|
||||
WidgetUtil.hideWithPadding(parent)
|
||||
} else {
|
||||
nonScrollableColoredBorderListView.layoutParams.height = nonScrollableColoredBorderListView.computeHeight()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,86 @@
|
||||
package sushi.hardcore.droidfs.adapters
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sushi.hardcore.droidfs.R
|
||||
|
||||
abstract class SelectableAdapter<T>(private val onSelectionChanged: (Int) -> Unit) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
var selectedItems: MutableSet<Int> = HashSet()
|
||||
|
||||
protected abstract fun getItems(): List<T>
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return getItems().size
|
||||
}
|
||||
|
||||
protected open fun toggleSelection(position: Int): Boolean {
|
||||
val isSelected = if (selectedItems.contains(position)) {
|
||||
selectedItems.remove(position)
|
||||
false
|
||||
} else {
|
||||
selectedItems.add(position)
|
||||
true
|
||||
}
|
||||
onSelectionChanged(selectedItems.size)
|
||||
return isSelected
|
||||
}
|
||||
|
||||
protected open fun onItemClick(position: Int): Boolean {
|
||||
if (selectedItems.isNotEmpty()) {
|
||||
return toggleSelection(position)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
protected open fun onItemLongClick(position: Int): Boolean {
|
||||
return toggleSelection(position)
|
||||
}
|
||||
|
||||
protected open fun isSelectable(position: Int): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
fun unselect(position: Int) {
|
||||
selectedItems.remove(position)
|
||||
onSelectionChanged(selectedItems.size)
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
for (i in getItems().indices) {
|
||||
if (!selectedItems.contains(i) && isSelectable(i)) {
|
||||
selectedItems.add(i)
|
||||
notifyItemChanged(i)
|
||||
}
|
||||
}
|
||||
onSelectionChanged(selectedItems.size)
|
||||
}
|
||||
|
||||
fun unSelectAll(notifyChange: Boolean) {
|
||||
if (notifyChange) {
|
||||
val whatWasSelected = selectedItems
|
||||
selectedItems = HashSet()
|
||||
whatWasSelected.forEach {
|
||||
notifyItemChanged(it)
|
||||
}
|
||||
} else {
|
||||
selectedItems.clear()
|
||||
}
|
||||
onSelectionChanged(selectedItems.size)
|
||||
}
|
||||
|
||||
private fun setBackground(rootView: View, isSelected: Boolean) {
|
||||
rootView.setBackgroundResource(if (isSelected) R.color.itemSelected else 0)
|
||||
}
|
||||
|
||||
protected fun setSelectable(element: View, rootView: View, position: Int) {
|
||||
element.setOnClickListener {
|
||||
setBackground(rootView, onItemClick(position))
|
||||
}
|
||||
element.setOnLongClickListener {
|
||||
setBackground(rootView, onItemLongClick(position))
|
||||
true
|
||||
}
|
||||
setBackground(rootView, selectedItems.contains(position))
|
||||
}
|
||||
}
|
@ -0,0 +1,128 @@
|
||||
package sushi.hardcore.droidfs.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.VolumeData
|
||||
import sushi.hardcore.droidfs.VolumeDatabase
|
||||
import sushi.hardcore.droidfs.VolumeManager
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
|
||||
class VolumeAdapter(
|
||||
private val context: Context,
|
||||
private val volumeDatabase: VolumeDatabase,
|
||||
private val volumeManager: VolumeManager,
|
||||
private val allowSelection: Boolean,
|
||||
private val showReadOnly: Boolean,
|
||||
private val listener: Listener,
|
||||
) : SelectableAdapter<VolumeData>(listener::onSelectionChanged) {
|
||||
private val inflater: LayoutInflater = LayoutInflater.from(context)
|
||||
lateinit var volumes: List<VolumeData>
|
||||
|
||||
init {
|
||||
reloadVolumes()
|
||||
volumeManager.observe(object : VolumeManager.Observer {
|
||||
override fun onVolumeStateChanged(volume: VolumeData) {
|
||||
notifyItemChanged(volumes.indexOf(volume))
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onAllVolumesClosed() {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onSelectionChanged(size: Int)
|
||||
fun onVolumeItemClick(volume: VolumeData, position: Int)
|
||||
fun onVolumeItemLongClick()
|
||||
}
|
||||
|
||||
override fun getItems(): List<VolumeData> {
|
||||
return volumes
|
||||
}
|
||||
|
||||
private fun reloadVolumes() {
|
||||
volumes = if (showReadOnly) {
|
||||
volumeDatabase.getVolumes()
|
||||
} else {
|
||||
volumeDatabase.getVolumes().filter { v -> v.canWrite(context.filesDir.path) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(position: Int): Boolean {
|
||||
listener.onVolumeItemClick(volumes[position], position)
|
||||
return if (allowSelection) {
|
||||
super.onItemClick(position)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemLongClick(position: Int): Boolean {
|
||||
listener.onVolumeItemLongClick()
|
||||
return if (allowSelection)
|
||||
super.onItemLongClick(position)
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
fun onVolumeDataChanged(position: Int) {
|
||||
reloadVolumes()
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun refresh() {
|
||||
reloadVolumes()
|
||||
unSelectAll(false)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
inner class VolumeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
fun bind(position: Int) {
|
||||
val volume = volumes[position]
|
||||
itemView.findViewById<TextView>(R.id.text_volume_name).text = volume.shortName
|
||||
itemView.findViewById<TextView>(R.id.text_path).text = if (volume.isHidden)
|
||||
context.getString(R.string.hidden_volume)
|
||||
else
|
||||
volume.name
|
||||
itemView.findViewById<ImageView>(R.id.icon_unlocked).isVisible = volumeManager.isOpen(volume)
|
||||
itemView.findViewById<ImageView>(R.id.icon_fingerprint).isVisible = volume.encryptedHash != null
|
||||
itemView.findViewById<TextView>(R.id.text_info).text = context.getString(
|
||||
if (volume.canWrite(context.filesDir.path)) {
|
||||
R.string.volume_type
|
||||
} else if (volume.canRead(context.filesDir.path)) {
|
||||
R.string.volume_type_read_only
|
||||
} else {
|
||||
R.string.volume_type_inaccessible
|
||||
},
|
||||
context.getString(if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) {
|
||||
R.string.gocryptfs
|
||||
} else {
|
||||
R.string.cryfs
|
||||
})
|
||||
)
|
||||
setSelectable(itemView.findViewById<LinearLayout>(R.id.selectable_container), itemView, layoutPosition)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val view: View = inflater.inflate(R.layout.adapter_volume, parent, false)
|
||||
return VolumeViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
(holder as VolumeViewHolder).bind(position)
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package sushi.hardcore.droidfs.add_volume
|
||||
|
||||
import sushi.hardcore.droidfs.R
|
||||
|
||||
enum class Action {
|
||||
OPEN,
|
||||
ADD,
|
||||
CREATE,
|
||||
;
|
||||
|
||||
fun getStringResId() = when (this) {
|
||||
OPEN -> R.string.open
|
||||
ADD -> R.string.add_volume
|
||||
CREATE -> R.string.create_volume
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
package sushi.hardcore.droidfs.add_volume
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.addCallback
|
||||
import sushi.hardcore.droidfs.*
|
||||
import sushi.hardcore.droidfs.databinding.ActivityAddVolumeBinding
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerRouter
|
||||
|
||||
class AddVolumeActivity: BaseActivity() {
|
||||
|
||||
companion object {
|
||||
const val RESULT_USER_BACK = 10
|
||||
}
|
||||
|
||||
private lateinit var binding: ActivityAddVolumeBinding
|
||||
private lateinit var explorerRouter: ExplorerRouter
|
||||
private lateinit var volumeOpener: VolumeOpener
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityAddVolumeBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
explorerRouter = ExplorerRouter(this, intent)
|
||||
volumeOpener = VolumeOpener(this)
|
||||
if (savedInstanceState == null) {
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.add(
|
||||
R.id.fragment_container,
|
||||
SelectPathFragment.newInstance(theme, explorerRouter.pickMode),
|
||||
)
|
||||
.commit()
|
||||
}
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
setResult(RESULT_USER_BACK)
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
if (supportFragmentManager.backStackEntryCount > 0)
|
||||
supportFragmentManager.popBackStack()
|
||||
else {
|
||||
setResult(RESULT_USER_BACK)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
fun onFragmentLoaded(selectPathFragment: Boolean) {
|
||||
title = getString(
|
||||
if (selectPathFragment) {
|
||||
R.string.add_volume
|
||||
} else {
|
||||
R.string.create_volume
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun startExplorer(volumeId: Int, volumeShortName: String) {
|
||||
startActivity(explorerRouter.getExplorerIntent(volumeId, volumeShortName))
|
||||
finish()
|
||||
}
|
||||
|
||||
fun onVolumeAdded() {
|
||||
setResult(RESULT_USER_BACK)
|
||||
finish()
|
||||
}
|
||||
|
||||
fun openVolume(volume: VolumeData, isVolumeKnown: Boolean) {
|
||||
volumeOpener.openVolume(volume, isVolumeKnown, object : VolumeOpener.VolumeOpenerCallbacks {
|
||||
override fun onVolumeOpened(id: Int) {
|
||||
startExplorer(id, volume.shortName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun createVolume(volumePath: String, isHidden: Boolean, rememberVolume: Boolean) {
|
||||
supportFragmentManager
|
||||
.beginTransaction()
|
||||
.replace(
|
||||
R.id.fragment_container, CreateVolumeFragment.newInstance(
|
||||
theme,
|
||||
volumePath,
|
||||
isHidden,
|
||||
rememberVolume,
|
||||
sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false),
|
||||
sharedPrefs.getBoolean("usf_fingerprint", false),
|
||||
)
|
||||
)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
}
|
@ -0,0 +1,307 @@
|
||||
package sushi.hardcore.droidfs.add_volume
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.RadioButton
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.children
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import sushi.hardcore.droidfs.BuildConfig
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.FingerprintProtector
|
||||
import sushi.hardcore.droidfs.LoadingTask
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.Theme
|
||||
import sushi.hardcore.droidfs.VolumeData
|
||||
import sushi.hardcore.droidfs.VolumeDatabase
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.databinding.FileSystemRadioBinding
|
||||
import sushi.hardcore.droidfs.databinding.FragmentCreateVolumeBinding
|
||||
import sushi.hardcore.droidfs.filesystems.CryfsVolume
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.util.Compat
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.UIUtils
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.io.File
|
||||
import java.util.Arrays
|
||||
|
||||
class CreateVolumeFragment: Fragment() {
|
||||
internal data class FileSystemInfo(val nameResource: Int, val detailsResource: Int, val ciphersResource: Int)
|
||||
|
||||
companion object {
|
||||
private const val KEY_THEME_VALUE = "theme"
|
||||
private const val KEY_VOLUME_PATH = "path"
|
||||
private const val KEY_IS_HIDDEN = "hidden"
|
||||
private const val KEY_REMEMBER_VOLUME = "remember"
|
||||
private const val KEY_PIN_PASSWORDS = Constants.PIN_PASSWORDS_KEY
|
||||
private const val KEY_USF_FINGERPRINT = "fingerprint"
|
||||
|
||||
private val GOCRYPTFS_INFO = FileSystemInfo(
|
||||
R.string.gocryptfs,
|
||||
R.string.gocryptfs_details,
|
||||
R.array.gocryptfs_encryption_ciphers,
|
||||
)
|
||||
private val CRYFS_INFO = FileSystemInfo(
|
||||
R.string.cryfs,
|
||||
R.string.cryfs_details,
|
||||
R.array.cryfs_encryption_ciphers,
|
||||
)
|
||||
|
||||
fun newInstance(
|
||||
theme: Theme,
|
||||
volumePath: String,
|
||||
isHidden: Boolean,
|
||||
rememberVolume: Boolean,
|
||||
pinPasswords: Boolean,
|
||||
usfFingerprint: Boolean,
|
||||
): CreateVolumeFragment {
|
||||
return CreateVolumeFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(KEY_THEME_VALUE, theme)
|
||||
putString(KEY_VOLUME_PATH, volumePath)
|
||||
putBoolean(KEY_IS_HIDDEN, isHidden)
|
||||
putBoolean(KEY_REMEMBER_VOLUME, rememberVolume)
|
||||
putBoolean(KEY_PIN_PASSWORDS, pinPasswords)
|
||||
putBoolean(KEY_USF_FINGERPRINT, usfFingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var binding: FragmentCreateVolumeBinding
|
||||
private lateinit var theme: Theme
|
||||
private val fileSystemInfos = ArrayList<FileSystemInfo>(2)
|
||||
private lateinit var volumePath: String
|
||||
private var isHiddenVolume: Boolean = false
|
||||
private var rememberVolume: Boolean = false
|
||||
private var usfFingerprint: Boolean = false
|
||||
private lateinit var volumeDatabase: VolumeDatabase
|
||||
private var fingerprintProtector: FingerprintProtector? = null
|
||||
private var hashStorageReset = false
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentCreateVolumeBinding.inflate(layoutInflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val pinPasswords = requireArguments().let { arguments ->
|
||||
theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
|
||||
volumePath = arguments.getString(KEY_VOLUME_PATH)!!
|
||||
isHiddenVolume = arguments.getBoolean(KEY_IS_HIDDEN)
|
||||
rememberVolume = arguments.getBoolean(KEY_REMEMBER_VOLUME)
|
||||
usfFingerprint = arguments.getBoolean(KEY_USF_FINGERPRINT)
|
||||
arguments.getBoolean(KEY_PIN_PASSWORDS)
|
||||
}
|
||||
volumeDatabase = VolumeDatabase(requireContext())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
fingerprintProtector = FingerprintProtector.new(requireActivity(), theme, volumeDatabase)
|
||||
}
|
||||
if (!rememberVolume || !usfFingerprint || fingerprintProtector == null) {
|
||||
binding.checkboxSavePassword.visibility = View.GONE
|
||||
}
|
||||
if (!BuildConfig.GOCRYPTFS_DISABLED) {
|
||||
fileSystemInfos.add(GOCRYPTFS_INFO)
|
||||
}
|
||||
if (!BuildConfig.CRYFS_DISABLED) {
|
||||
fileSystemInfos.add(CRYFS_INFO)
|
||||
}
|
||||
val encryptionCipherAdapter = ArrayAdapter(
|
||||
requireContext(),
|
||||
android.R.layout.simple_spinner_item,
|
||||
resources.getStringArray(R.array.gocryptfs_encryption_ciphers).toMutableList()
|
||||
).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
for ((i, fs) in fileSystemInfos.iterator().withIndex()) {
|
||||
with(FileSystemRadioBinding.inflate(layoutInflater)) {
|
||||
title.text = getString(fs.nameResource)
|
||||
details.text = getString(fs.detailsResource)
|
||||
radio.isChecked = i == 0
|
||||
root.setOnClickListener {
|
||||
radio.performClick()
|
||||
}
|
||||
radio.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (isChecked) {
|
||||
with(encryptionCipherAdapter) {
|
||||
clear()
|
||||
addAll(resources.getStringArray(fs.ciphersResource).asList())
|
||||
}
|
||||
binding.radioGroupFilesystems.children.forEach {
|
||||
if (it != root) {
|
||||
it.findViewById<RadioButton>(R.id.radio).isChecked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.radioGroupFilesystems.addView(root)
|
||||
}
|
||||
}
|
||||
binding.spinnerCipher.adapter = encryptionCipherAdapter
|
||||
if (pinPasswords) {
|
||||
arrayOf(binding.editPassword, binding.editPasswordConfirm).forEach {
|
||||
it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
binding.editPasswordConfirm.setOnEditorActionListener { _, _, _ ->
|
||||
createVolume()
|
||||
true
|
||||
}
|
||||
binding.buttonCreate.setOnClickListener {
|
||||
createVolume()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
(activity as AddVolumeActivity).onFragmentLoaded(false)
|
||||
}
|
||||
|
||||
private fun getSelectedFileSystemIndex(): Int {
|
||||
for ((i, child) in binding.radioGroupFilesystems.children.iterator().withIndex()) {
|
||||
if (child.findViewById<RadioButton>(R.id.radio).isChecked) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun createVolume() {
|
||||
val password = UIUtils.encodeEditTextContent(binding.editPassword)
|
||||
val passwordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
|
||||
if (!password.contentEquals(passwordConfirm)) {
|
||||
Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
|
||||
Arrays.fill(password, 0)
|
||||
Arrays.fill(passwordConfirm, 0)
|
||||
} else {
|
||||
Arrays.fill(passwordConfirm, 0)
|
||||
val returnedHash: ObjRef<ByteArray?>? = if (binding.checkboxSavePassword.isChecked) {
|
||||
ObjRef(null)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val encryptedVolume = ObjRef<EncryptedVolume?>(null)
|
||||
object: LoadingTask<Byte>(requireActivity() as AppCompatActivity, theme, R.string.loading_msg_create) {
|
||||
private fun generateResult(success: Boolean, volumeType: Byte): Byte {
|
||||
return if (success) {
|
||||
volumeType
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doTask(): Byte {
|
||||
val volumeFile = File(volumePath)
|
||||
if (!volumeFile.exists())
|
||||
volumeFile.mkdirs()
|
||||
val result = if (fileSystemInfos[getSelectedFileSystemIndex()] == GOCRYPTFS_INFO) {
|
||||
val xchacha = when (binding.spinnerCipher.selectedItemPosition) {
|
||||
0 -> -1 // auto
|
||||
1 -> 0 // AES-GCM
|
||||
else -> 1 // XChaCha20-Poly1305
|
||||
}
|
||||
generateResult(GocryptfsVolume.createAndOpenVolume(
|
||||
volumePath,
|
||||
password,
|
||||
false,
|
||||
xchacha,
|
||||
returnedHash?.apply {
|
||||
value = ByteArray(GocryptfsVolume.KeyLen)
|
||||
}?.value,
|
||||
encryptedVolume,
|
||||
), EncryptedVolume.GOCRYPTFS_VOLUME_TYPE)
|
||||
} else {
|
||||
encryptedVolume.value = CryfsVolume.create(
|
||||
volumePath,
|
||||
CryfsVolume.getLocalStateDir(activity.filesDir.path),
|
||||
password,
|
||||
returnedHash,
|
||||
resources.getStringArray(R.array.cryfs_encryption_ciphers)[binding.spinnerCipher.selectedItemPosition],
|
||||
)
|
||||
generateResult(encryptedVolume.value != null, EncryptedVolume.CRYFS_VOLUME_TYPE)
|
||||
}
|
||||
Arrays.fill(password, 0)
|
||||
return result
|
||||
}
|
||||
}.startTask(lifecycleScope) { result ->
|
||||
if (result.compareTo(-1) == 0) {
|
||||
CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.create_volume_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath
|
||||
val volume = VolumeData(VolumeData.newUuid(), volumeName, isHiddenVolume, result)
|
||||
var isVolumeSaved = false
|
||||
volumeDatabase.apply {
|
||||
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
|
||||
removeVolume(volume)
|
||||
if (rememberVolume) {
|
||||
isVolumeSaved = saveVolume(volume)
|
||||
}
|
||||
}
|
||||
val volumeId = encryptedVolume.value?.let {
|
||||
(activity?.application as VolumeManagerApp).volumeManager.insert(it, volume)
|
||||
}
|
||||
@SuppressLint("NewApi") // if fingerprintProtector is null checkboxSavePassword is hidden
|
||||
if (isVolumeSaved && binding.checkboxSavePassword.isChecked && returnedHash != null) {
|
||||
fingerprintProtector!!.let {
|
||||
it.listener = object : FingerprintProtector.Listener {
|
||||
override fun onHashStorageReset() {
|
||||
hashStorageReset = true
|
||||
// retry
|
||||
it.savePasswordHash(volume, returnedHash.value!!)
|
||||
}
|
||||
override fun onPasswordHashDecrypted(hash: ByteArray) {} // shouldn't happen here
|
||||
override fun onPasswordHashSaved() {
|
||||
Arrays.fill(returnedHash.value!!, 0)
|
||||
onVolumeCreated(volumeId, volume.shortName)
|
||||
}
|
||||
override fun onFailed(pending: Boolean) {
|
||||
if (!pending) {
|
||||
Arrays.fill(returnedHash.value!!, 0)
|
||||
onVolumeCreated(volumeId, volume.shortName)
|
||||
}
|
||||
}
|
||||
}
|
||||
it.savePasswordHash(volume, returnedHash.value!!)
|
||||
}
|
||||
} else {
|
||||
onVolumeCreated(volumeId, volume.shortName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onVolumeCreated(id: Int?, volumeShortName: String) {
|
||||
(activity as AddVolumeActivity).apply {
|
||||
if (id == null) {
|
||||
finish()
|
||||
} else {
|
||||
startExplorer(id, volumeShortName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
binding.editPassword.text.clear()
|
||||
binding.editPasswordConfirm.text.clear()
|
||||
}
|
||||
}
|
@ -0,0 +1,408 @@
|
||||
package sushi.hardcore.droidfs.add_volume
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.preference.PreferenceManager
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.Theme
|
||||
import sushi.hardcore.droidfs.VolumeData
|
||||
import sushi.hardcore.droidfs.VolumeDatabase
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.databinding.DialogSdcardErrorBinding
|
||||
import sushi.hardcore.droidfs.databinding.FragmentSelectPathBinding
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.Compat
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.io.File
|
||||
|
||||
class SelectPathFragment: Fragment() {
|
||||
internal class InputViewModel: ViewModel() {
|
||||
var showEditText = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_THEME_VALUE = "theme"
|
||||
private const val KEY_PICK_MODE = "pick"
|
||||
|
||||
fun newInstance(theme: Theme, pickMode: Boolean): SelectPathFragment {
|
||||
return SelectPathFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(KEY_THEME_VALUE, theme)
|
||||
putBoolean(KEY_PICK_MODE, pickMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var binding: FragmentSelectPathBinding
|
||||
private val askStoragePermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
if (result[Manifest.permission.READ_EXTERNAL_STORAGE] == true && result[Manifest.permission.WRITE_EXTERNAL_STORAGE] == true)
|
||||
launchPickDirectory()
|
||||
else
|
||||
CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.storage_perm_denied)
|
||||
.setMessage(R.string.storage_perm_denied_msg)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null)
|
||||
onDirectoryPicked(uri)
|
||||
}
|
||||
private lateinit var app: VolumeManagerApp
|
||||
private lateinit var theme: Theme
|
||||
private lateinit var volumeDatabase: VolumeDatabase
|
||||
private lateinit var filesDir: String
|
||||
private lateinit var sharedPrefs: SharedPreferences
|
||||
private var pickMode = false
|
||||
private var originalRememberVolume = true
|
||||
private var currentVolumeData: VolumeData? = null
|
||||
private var volumeAction: Action? = null
|
||||
private val inputViewModel: InputViewModel by viewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = FragmentSelectPathBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
app = requireActivity().application as VolumeManagerApp
|
||||
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
originalRememberVolume = sharedPrefs.getBoolean(Constants.REMEMBER_VOLUME_KEY, true)
|
||||
binding.switchRemember.isChecked = originalRememberVolume
|
||||
arguments?.let { arguments ->
|
||||
theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
|
||||
pickMode = arguments.getBoolean(KEY_PICK_MODE)
|
||||
}
|
||||
volumeDatabase = VolumeDatabase(requireContext())
|
||||
filesDir = requireContext().filesDir.path
|
||||
binding.containerHiddenVolume.setOnClickListener {
|
||||
binding.switchHiddenVolume.performClick()
|
||||
}
|
||||
binding.switchHiddenVolume.setOnClickListener {
|
||||
updateUi()
|
||||
}
|
||||
binding.buttonPickDirectory.setOnClickListener {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (Environment.isExternalStorageManager()) {
|
||||
launchPickDirectory()
|
||||
} else {
|
||||
app.isStartingExternalApp = true
|
||||
startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:"+requireContext().packageName)))
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
) + ContextCompat.checkSelfPermission(
|
||||
requireContext(),
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
launchPickDirectory()
|
||||
} else {
|
||||
app.isStartingExternalApp = true
|
||||
askStoragePermissions.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
launchPickDirectory()
|
||||
}
|
||||
}
|
||||
binding.buttonEnterPath.setOnClickListener {
|
||||
inputViewModel.showEditText = true
|
||||
updateUi()
|
||||
binding.editVolumeName.requestFocus()
|
||||
(app.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(
|
||||
binding.editVolumeName,
|
||||
InputMethodManager.SHOW_IMPLICIT
|
||||
)
|
||||
}
|
||||
binding.editVolumeName.addTextChangedListener(object: TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
|
||||
updateUi(s)
|
||||
}
|
||||
})
|
||||
binding.switchRemember.setOnCheckedChangeListener { _, _ -> updateUi() }
|
||||
binding.editVolumeName.setOnEditorActionListener { _, _, _ ->
|
||||
if (binding.editVolumeName.text.isEmpty()) {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
if (binding.switchHiddenVolume.isChecked) R.string.empty_volume_name else R.string.empty_volume_path,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
onPathSelected()
|
||||
}
|
||||
true
|
||||
}
|
||||
binding.buttonAction.setOnClickListener { onPathSelected() }
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
(activity as AddVolumeActivity).onFragmentLoaded(true)
|
||||
}
|
||||
|
||||
private fun launchPickDirectory() {
|
||||
app.isStartingExternalApp = true
|
||||
PathUtils.safePickDirectory(pickDirectory, requireContext(), theme)
|
||||
}
|
||||
|
||||
private fun updateUi(volumeName: CharSequence = binding.editVolumeName.text) {
|
||||
var warning = -1
|
||||
fun updateWarning() {
|
||||
if (warning == -1) {
|
||||
binding.textWarning.isVisible = false
|
||||
} else {
|
||||
binding.textWarning.isVisible = true
|
||||
binding.textWarning.text = getString(warning)
|
||||
}
|
||||
}
|
||||
|
||||
val hidden = binding.switchHiddenVolume.isChecked
|
||||
binding.editVolumeName.isVisible = hidden || inputViewModel.showEditText
|
||||
binding.buttonPickDirectory.isVisible = !hidden
|
||||
binding.textOr.isVisible = !hidden && !inputViewModel.showEditText
|
||||
binding.buttonEnterPath.isVisible = !hidden && !inputViewModel.showEditText
|
||||
if (hidden) {
|
||||
binding.textLabel.text = getString(R.string.volume_name_label)
|
||||
binding.editVolumeName.hint = getString(R.string.volume_name_hint)
|
||||
} else {
|
||||
binding.textLabel.text = getString(R.string.volume_path_label)
|
||||
binding.editVolumeName.hint = getString(R.string.volume_path_hint)
|
||||
}
|
||||
if (hidden && volumeName.contains(PathUtils.SEPARATOR)) {
|
||||
warning = R.string.error_slash_in_name
|
||||
}
|
||||
// exit early if possible to avoid filesystem queries
|
||||
if (volumeName.isEmpty() || warning != -1 || (!hidden && !inputViewModel.showEditText)) {
|
||||
binding.buttonAction.isVisible = false
|
||||
binding.switchRemember.isVisible = false
|
||||
updateWarning()
|
||||
return
|
||||
}
|
||||
val path = File(getCurrentVolumePath())
|
||||
volumeAction = if (path.isDirectory) {
|
||||
if (path.list()?.isEmpty() == true) {
|
||||
Action.CREATE
|
||||
} else if (pickMode || !binding.switchRemember.isChecked) {
|
||||
Action.OPEN
|
||||
} else {
|
||||
Action.ADD
|
||||
}
|
||||
} else {
|
||||
Action.CREATE
|
||||
}
|
||||
val valid = !(volumeAction == Action.CREATE && pickMode)
|
||||
binding.switchRemember.isVisible = valid
|
||||
binding.buttonAction.isVisible = valid
|
||||
if (valid) {
|
||||
binding.buttonAction.text = getString(volumeAction!!.getStringResId())
|
||||
currentVolumeData = if (volumeAction == Action.CREATE) {
|
||||
null
|
||||
} else {
|
||||
volumeDatabase.getVolume(volumeName.toString(), hidden)
|
||||
}
|
||||
if (currentVolumeData != null) {
|
||||
warning = R.string.volume_alread_saved
|
||||
}
|
||||
} else {
|
||||
warning = R.string.choose_existing_volume
|
||||
}
|
||||
updateWarning()
|
||||
}
|
||||
|
||||
private fun onDirectoryPicked(uri: Uri) {
|
||||
val path = PathUtils.getFullPathFromTreeUri(uri, requireContext())
|
||||
if (path == null) {
|
||||
CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.path_error)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
inputViewModel.showEditText = true
|
||||
binding.editVolumeName.setText(path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentVolumePath(): String {
|
||||
return if (binding.switchHiddenVolume.isChecked)
|
||||
VolumeData.getHiddenVolumeFullPath(filesDir, binding.editVolumeName.text.toString())
|
||||
else
|
||||
binding.editVolumeName.text.toString()
|
||||
}
|
||||
|
||||
private fun onPathSelected() {
|
||||
if (binding.switchRemember.isChecked != originalRememberVolume) {
|
||||
with(sharedPrefs.edit()) {
|
||||
putBoolean(Constants.REMEMBER_VOLUME_KEY, binding.switchRemember.isChecked)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
if (currentVolumeData == null) { // volume not known
|
||||
val currentVolumeValue = binding.editVolumeName.text.toString()
|
||||
val isHidden = binding.switchHiddenVolume.isChecked
|
||||
if (isHidden && currentVolumeValue.contains(PathUtils.SEPARATOR)) {
|
||||
Toast.makeText(requireContext(), R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
|
||||
} else if (isHidden && volumeAction == Action.CREATE) {
|
||||
CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.hidden_volume_warning)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
onNewVolumeSelected(currentVolumeValue, isHidden)
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
onNewVolumeSelected(currentVolumeValue, isHidden)
|
||||
}
|
||||
} else {
|
||||
with (activity as AddVolumeActivity) {
|
||||
if (volumeAction!! == Action.OPEN) {
|
||||
openVolume(currentVolumeData!!, true)
|
||||
} else {
|
||||
onVolumeAdded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNewVolumeSelected(currentVolumeValue: String, isHidden: Boolean) {
|
||||
val volumePath = getCurrentVolumePath()
|
||||
if (volumeAction!! == Action.CREATE) {
|
||||
val volumeFile = File(volumePath)
|
||||
var goodDirectory = false
|
||||
if (volumeFile.isFile) {
|
||||
Toast.makeText(requireContext(), R.string.error_is_file, Toast.LENGTH_SHORT).show()
|
||||
} else if (volumeFile.isDirectory) {
|
||||
val dirContent = volumeFile.list()
|
||||
if (dirContent != null) {
|
||||
if (dirContent.isEmpty()) {
|
||||
if (volumeFile.canWrite()) {
|
||||
goodDirectory = true
|
||||
} else {
|
||||
errorDirectoryNotWritable(volumePath)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.dir_not_empty, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
R.string.listdir_null_error_msg,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
if (File(PathUtils.getParentPath(volumePath)).canWrite()) {
|
||||
goodDirectory = true
|
||||
} else {
|
||||
errorDirectoryNotWritable(volumePath)
|
||||
}
|
||||
}
|
||||
if (goodDirectory) {
|
||||
(activity as AddVolumeActivity).createVolume(
|
||||
volumePath,
|
||||
isHidden,
|
||||
binding.switchRemember.isChecked
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val volumeType = EncryptedVolume.getVolumeType(volumePath)
|
||||
if (volumeType < 0) {
|
||||
CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.error_not_a_volume)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else if (!File(volumePath).canWrite()) {
|
||||
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
|
||||
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext())) {
|
||||
dialog.setView(
|
||||
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
|
||||
path.text = PathUtils.getPackageDataFolder(requireContext())
|
||||
footer.text = getString(R.string.sdcard_error_add_footer)
|
||||
}.root
|
||||
)
|
||||
} else {
|
||||
dialog.setMessage(R.string.add_cant_write_warning)
|
||||
}
|
||||
dialog.show()
|
||||
} else {
|
||||
onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// called when the user tries to create a volume in a non-writable directory
|
||||
private fun errorDirectoryNotWritable(volumePath: String) {
|
||||
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.error)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
@SuppressLint("InflateParams")
|
||||
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext()))
|
||||
dialog.setView(
|
||||
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
|
||||
path.text = PathUtils.getPackageDataFolder(requireContext())
|
||||
}.root
|
||||
)
|
||||
else
|
||||
dialog.setMessage(R.string.create_cant_write_error_msg)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun onExistingVolumeSelected(volumeName: String, isHidden: Boolean, volumeType: Byte) {
|
||||
val volumeData = VolumeData(VolumeData.newUuid(), volumeName, isHidden, volumeType)
|
||||
if (binding.switchRemember.isChecked) {
|
||||
volumeDatabase.saveVolume(volumeData)
|
||||
}
|
||||
with (activity as AddVolumeActivity) {
|
||||
if (volumeAction!! == Action.OPEN) {
|
||||
openVolume(volumeData, binding.switchRemember.isChecked)
|
||||
} else {
|
||||
onVolumeAdded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Code borrowed from the awesome Material Files app (https://github.com/zhanghai/MaterialFiles)
|
||||
*
|
||||
* Copyright (c) 2019 Hai Zhang <dreaming.in.code.zh@gmail.com>
|
||||
* All Rights Reserved.
|
||||
*/
|
||||
|
||||
package sushi.hardcore.droidfs.collation
|
||||
|
||||
import kotlin.math.min
|
||||
|
||||
class ByteString internal constructor(
|
||||
private val bytes: ByteArray
|
||||
) : Comparable<ByteString> {
|
||||
|
||||
fun borrowBytes(): ByteArray = bytes
|
||||
|
||||
private var stringCache: String? = null
|
||||
|
||||
override fun toString(): String {
|
||||
// We are okay with the potential race condition here.
|
||||
var string = stringCache
|
||||
if (string == null) {
|
||||
// String() uses replacement char instead of throwing exception.
|
||||
string = String(bytes)
|
||||
stringCache = string
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) {
|
||||
return true
|
||||
}
|
||||
if (javaClass != other?.javaClass) {
|
||||
return false
|
||||
}
|
||||
other as ByteString
|
||||
return bytes contentEquals other.bytes
|
||||
}
|
||||
|
||||
override fun hashCode(): Int = bytes.contentHashCode()
|
||||
|
||||
override fun compareTo(other: ByteString): Int = bytes.compareTo(other.bytes)
|
||||
|
||||
private fun ByteArray.compareTo(other: ByteArray): Int {
|
||||
val size = size
|
||||
val otherSize = other.size
|
||||
for (index in 0 until min(size, otherSize)) {
|
||||
val byte = this[index]
|
||||
val otherByte = other[index]
|
||||
val result = byte - otherByte
|
||||
if (result != 0) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return size - otherSize
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromBytes(bytes: ByteArray, start: Int = 0, end: Int = bytes.size): ByteString =
|
||||
ByteString(bytes.copyOfRange(start, end))
|
||||
|
||||
fun fromString(string: String): ByteString =
|
||||
ByteString(string.toByteArray()).apply { stringCache = string }
|
||||
}
|
||||
}
|
||||
|
||||
fun ByteArray.toByteString(start: Int = 0, end: Int = size): ByteString =
|
||||
ByteString.fromBytes(this, start, end)
|
||||
|
||||
fun String.toByteString(): ByteString = ByteString.fromString(this)
|
@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Code borrowed from the awesome Material Files app (https://github.com/zhanghai/MaterialFiles)
|
||||
*
|
||||
* Copyright (c) 2019 Hai Zhang <dreaming.in.code.zh@gmail.com>
|
||||
* All Rights Reserved.
|
||||
*/
|
||||
|
||||
package sushi.hardcore.droidfs.collation
|
||||
|
||||
class ByteStringBuilder(capacity: Int = 16) {
|
||||
private var bytes = ByteArray(capacity)
|
||||
|
||||
var length = 0
|
||||
private set
|
||||
|
||||
fun append(byte: Byte): ByteStringBuilder {
|
||||
ensureCapacity(length + 1)
|
||||
bytes[length] = byte
|
||||
++length
|
||||
return this
|
||||
}
|
||||
|
||||
fun append(bytes: ByteArray, start: Int = 0, end: Int = bytes.size): ByteStringBuilder {
|
||||
val newLength = length + (end - start)
|
||||
ensureCapacity(newLength)
|
||||
bytes.copyInto(this.bytes, length, start, end)
|
||||
length = newLength
|
||||
return this
|
||||
}
|
||||
|
||||
fun append(byteString: ByteString): ByteStringBuilder = append(byteString.borrowBytes())
|
||||
|
||||
private fun ensureCapacity(minimumCapacity: Int) {
|
||||
val capacity = bytes.size
|
||||
if (minimumCapacity > capacity) {
|
||||
var newCapacity = (capacity shl 1) + 2
|
||||
if (newCapacity < minimumCapacity) {
|
||||
newCapacity = minimumCapacity
|
||||
}
|
||||
bytes = bytes.copyOf(newCapacity)
|
||||
}
|
||||
}
|
||||
|
||||
fun toByteString(): ByteString = bytes.toByteString(0, length)
|
||||
|
||||
override fun toString(): String = String(bytes, 0, length)
|
||||
}
|
@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Code borrowed from the awesome Material Files app (https://github.com/zhanghai/MaterialFiles)
|
||||
*
|
||||
* Copyright (c) 2020 Hai Zhang <dreaming.in.code.zh@gmail.com>
|
||||
* All Rights Reserved.
|
||||
*/
|
||||
|
||||
package sushi.hardcore.droidfs.collation
|
||||
|
||||
import java.text.CollationKey
|
||||
import java.text.Collator
|
||||
import kotlin.math.min
|
||||
|
||||
private val COLLATION_SENTINEL = byteArrayOf(1, 1, 1)
|
||||
|
||||
// @see https://github.com/GNOME/glib/blob/mainline/glib/gunicollate.c
|
||||
// g_utf8_collate_key_for_filename()
|
||||
fun Collator.getCollationKeyForFileName(source: String): CollationKey {
|
||||
val result = ByteStringBuilder()
|
||||
val suffix = ByteStringBuilder()
|
||||
var previousIndex = 0
|
||||
var index = 0
|
||||
val endIndex = source.length
|
||||
while (index < endIndex) {
|
||||
when {
|
||||
source[index] == '.' -> {
|
||||
if (previousIndex != index) {
|
||||
val collationKey = getCollationKey(source.substring(previousIndex, index))
|
||||
result.append(collationKey.toByteArray())
|
||||
}
|
||||
result.append(COLLATION_SENTINEL).append(1)
|
||||
previousIndex = index + 1
|
||||
}
|
||||
source[index].isAsciiDigit() -> {
|
||||
if (previousIndex != index) {
|
||||
val collationKey = getCollationKey(source.substring(previousIndex, index))
|
||||
result.append(collationKey.toByteArray())
|
||||
}
|
||||
result.append(COLLATION_SENTINEL).append(2)
|
||||
previousIndex = index
|
||||
var leadingZeros: Int
|
||||
var digits: Int
|
||||
if (source[index] == '0') {
|
||||
leadingZeros = 1
|
||||
digits = 0
|
||||
} else {
|
||||
leadingZeros = 0
|
||||
digits = 1
|
||||
}
|
||||
while (++index < endIndex) {
|
||||
if (source[index] == '0' && digits == 0) {
|
||||
++leadingZeros
|
||||
} else if (source[index].isAsciiDigit()) {
|
||||
++digits
|
||||
} else {
|
||||
if (digits == 0) {
|
||||
++digits
|
||||
--leadingZeros
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
while (digits > 1) {
|
||||
result.append(':'.code.toByte())
|
||||
--digits
|
||||
}
|
||||
if (leadingZeros > 0) {
|
||||
suffix.append(leadingZeros.toByte())
|
||||
previousIndex += leadingZeros
|
||||
}
|
||||
result.append(source.substring(previousIndex, index).toByteString())
|
||||
previousIndex = index
|
||||
--index
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
++index
|
||||
}
|
||||
if (previousIndex != index) {
|
||||
val collationKey = getCollationKey(source.substring(previousIndex, index))
|
||||
result.append(collationKey.toByteArray())
|
||||
}
|
||||
result.append(suffix.toByteString())
|
||||
return ByteArrayCollationKey(source, result.toByteString().borrowBytes())
|
||||
}
|
||||
|
||||
private fun Char.isAsciiDigit(): Boolean = this in '0'..'9'
|
||||
|
||||
private class ByteArrayCollationKey(
|
||||
@Suppress("CanBeParameter")
|
||||
private val source: String,
|
||||
private val bytes: ByteArray
|
||||
) : CollationKey(source) {
|
||||
override fun compareTo(other: CollationKey): Int {
|
||||
other as ByteArrayCollationKey
|
||||
return bytes.unsignedCompareTo(other.bytes)
|
||||
}
|
||||
|
||||
override fun toByteArray(): ByteArray = bytes.copyOf()
|
||||
}
|
||||
|
||||
private fun ByteArray.unsignedCompareTo(other: ByteArray): Int {
|
||||
val size = size
|
||||
val otherSize = other.size
|
||||
for (index in 0 until min(size, otherSize)) {
|
||||
val byte = this[index].toInt() and 0xFF
|
||||
val otherByte = other[index].toInt() and 0xFF
|
||||
if (byte < otherByte) {
|
||||
return -1
|
||||
} else if (byte > otherByte) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return size - otherSize
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
package sushi.hardcore.droidfs.content_providers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import sushi.hardcore.droidfs.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.LoadingTask
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import java.io.File
|
||||
|
||||
object ExternalProvider {
|
||||
private const val content_type_all = "*/*"
|
||||
private var storedFiles: MutableList<Uri> = ArrayList()
|
||||
private fun getContentType(filename: String, previous_content_type: String?): String? {
|
||||
if (content_type_all != previous_content_type) {
|
||||
var contentType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(filename).extension)
|
||||
if (contentType == null) {
|
||||
contentType = content_type_all
|
||||
}
|
||||
if (previous_content_type == null) {
|
||||
return contentType
|
||||
} else if (previous_content_type != contentType) {
|
||||
return content_type_all
|
||||
}
|
||||
}
|
||||
return previous_content_type
|
||||
}
|
||||
|
||||
private fun exportFile(context: Context, gocryptfsVolume: GocryptfsVolume, file_path: String, previous_content_type: String?): Pair<Uri?, String?> {
|
||||
val fileName = File(file_path).name
|
||||
val tmpFileUri = RestrictedFileProvider.newFile(fileName)
|
||||
if (tmpFileUri != null){
|
||||
storedFiles.add(tmpFileUri)
|
||||
if (gocryptfsVolume.exportFile(context, file_path, tmpFileUri)) {
|
||||
return Pair(tmpFileUri, getContentType(fileName, previous_content_type))
|
||||
}
|
||||
}
|
||||
return Pair(null, null)
|
||||
}
|
||||
|
||||
fun share(activity: AppCompatActivity, gocryptfsVolume: GocryptfsVolume, file_paths: List<String>) {
|
||||
object : LoadingTask(activity, R.string.loading_msg_export){
|
||||
override fun doTask(activity: AppCompatActivity) {
|
||||
var contentType: String? = null
|
||||
val uris = ArrayList<Uri>()
|
||||
for (path in file_paths) {
|
||||
val result = exportFile(activity, gocryptfsVolume, path, contentType)
|
||||
contentType = if (result.first != null) {
|
||||
uris.add(result.first!!)
|
||||
result.second
|
||||
} else {
|
||||
stopTask {
|
||||
ColoredAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(activity.getString(R.string.export_failed, path))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
val shareIntent = Intent()
|
||||
shareIntent.type = contentType
|
||||
if (uris.size == 1) {
|
||||
shareIntent.action = Intent.ACTION_SEND
|
||||
shareIntent.putExtra(Intent.EXTRA_STREAM, uris[0])
|
||||
} else {
|
||||
shareIntent.action = Intent.ACTION_SEND_MULTIPLE
|
||||
shareIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||
}
|
||||
stopTask {
|
||||
activity.startActivity(Intent.createChooser(shareIntent, activity.getString(R.string.share_chooser)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun open(activity: AppCompatActivity, gocryptfsVolume: GocryptfsVolume, file_path: String) {
|
||||
object : LoadingTask(activity, R.string.loading_msg_export) {
|
||||
override fun doTask(activity: AppCompatActivity) {
|
||||
val result = exportFile(activity, gocryptfsVolume, file_path, null)
|
||||
if (result.first != null) {
|
||||
val openIntent = Intent(Intent.ACTION_VIEW)
|
||||
openIntent.setDataAndType(result.first, result.second)
|
||||
stopTask { activity.startActivity(openIntent) }
|
||||
} else {
|
||||
stopTask {
|
||||
ColoredAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(activity.getString(R.string.export_failed, file_path))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFiles(context: Context) {
|
||||
Thread{
|
||||
val success = ArrayList<Uri>()
|
||||
for (uri in storedFiles){
|
||||
if (context.contentResolver.delete(uri, null, null) == 1){
|
||||
success.add(uri)
|
||||
}
|
||||
}
|
||||
for (uri in success){
|
||||
storedFiles.remove(uri)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -0,0 +1,147 @@
|
||||
package sushi.hardcore.droidfs.content_providers
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.BuildConfig
|
||||
import sushi.hardcore.droidfs.EncryptedFileProvider
|
||||
import sushi.hardcore.droidfs.VolumeManager
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||
import sushi.hardcore.droidfs.util.Wiper
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
class TemporaryFileProvider : ContentProvider() {
|
||||
private inner class ProvidedFile(
|
||||
val file: EncryptedFileProvider.ExportedFile,
|
||||
val size: Long,
|
||||
val volumeId: Int
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TemporaryFileProvider"
|
||||
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".temporary_provider"
|
||||
private val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")
|
||||
|
||||
lateinit var instance: TemporaryFileProvider
|
||||
private set
|
||||
}
|
||||
|
||||
private val usfSafWriteDelegate = AndroidUtils.LiveBooleanPreference("usf_saf_write", false)
|
||||
private val usfSafWrite by usfSafWriteDelegate
|
||||
private lateinit var volumeManager: VolumeManager
|
||||
lateinit var encryptedFileProvider: EncryptedFileProvider
|
||||
private val files = HashMap<Uri, ProvidedFile>()
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
return context?.let {
|
||||
volumeManager = (it.applicationContext as VolumeManagerApp).volumeManager
|
||||
usfSafWriteDelegate.init(it)
|
||||
encryptedFileProvider = EncryptedFileProvider(it)
|
||||
instance = this
|
||||
val tmpFilesDir = EncryptedFileProvider.getTmpFilesDir(it)
|
||||
val success = tmpFilesDir.mkdirs()
|
||||
// wipe any additional files not previously deleted
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
tmpFilesDir.listFiles()?.onEach { f -> Wiper.wipe(f) }
|
||||
}
|
||||
success
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun exportFile(
|
||||
exportedFile: EncryptedFileProvider.ExportedFile,
|
||||
size: Long,
|
||||
volumeId: Int
|
||||
): Uri? {
|
||||
if (!encryptedFileProvider.exportFile(exportedFile, volumeManager.getVolume(volumeId)!!)) {
|
||||
return null
|
||||
}
|
||||
return Uri.withAppendedPath(BASE_URI, UUID.randomUUID().toString()).also {
|
||||
files[it] = ProvidedFile(exportedFile, size, volumeId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?,
|
||||
sortOrder: String?
|
||||
): Cursor? {
|
||||
val file = files[uri] ?: return null
|
||||
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), 1).apply {
|
||||
addRow(arrayOf(File(file.file.path).name, file.size))
|
||||
}
|
||||
}
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri? {
|
||||
throw UnsupportedOperationException("Operation not supported")
|
||||
}
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<String>?
|
||||
): Int {
|
||||
throw UnsupportedOperationException("Operation not supported")
|
||||
}
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
return if (files.remove(uri)?.file?.also { it.free() } == null) 0 else 1
|
||||
}
|
||||
|
||||
override fun getType(uri: Uri): String = files[uri]?.file?.path?.let {
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(it).extension)
|
||||
} ?: "application/octet-stream"
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||
files[uri]?.let { file ->
|
||||
val encryptedVolume = volumeManager.getVolume(file.volumeId) ?: run {
|
||||
Log.e(TAG, "Volume closed for $uri")
|
||||
return null
|
||||
}
|
||||
val result = encryptedFileProvider.openFile(
|
||||
file.file,
|
||||
mode,
|
||||
encryptedVolume,
|
||||
volumeManager.getCoroutineScope(file.volumeId),
|
||||
false,
|
||||
usfSafWrite,
|
||||
)
|
||||
when (result.second) {
|
||||
EncryptedFileProvider.Error.SUCCESS -> return result.first!!
|
||||
EncryptedFileProvider.Error.WRITE_ACCESS_DENIED -> Log.e(
|
||||
TAG,
|
||||
"Unauthorized write access requested from $callingPackage to $uri"
|
||||
)
|
||||
|
||||
else -> result.second.log()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// this must not be cancelled
|
||||
fun wipe() = GlobalScope.launch(Dispatchers.IO) {
|
||||
context!!.revokeUriPermission(BASE_URI, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
synchronized(this@TemporaryFileProvider) {
|
||||
for (i in files.values) {
|
||||
i.file.free()
|
||||
}
|
||||
files.clear()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,294 @@
|
||||
package sushi.hardcore.droidfs.content_providers
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsProvider
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.preference.PreferenceManager
|
||||
import sushi.hardcore.droidfs.BuildConfig
|
||||
import sushi.hardcore.droidfs.EncryptedFileProvider
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.VolumeData
|
||||
import sushi.hardcore.droidfs.VolumeManager
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.filesystems.Stat
|
||||
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import java.io.File
|
||||
|
||||
class VolumeProvider: DocumentsProvider() {
|
||||
companion object {
|
||||
private const val TAG = "DocumentsProvider"
|
||||
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".volume_provider"
|
||||
private val DEFAULT_ROOT_PROJECTION = arrayOf(
|
||||
DocumentsContract.Root.COLUMN_ROOT_ID,
|
||||
DocumentsContract.Root.COLUMN_FLAGS,
|
||||
DocumentsContract.Root.COLUMN_ICON,
|
||||
DocumentsContract.Root.COLUMN_TITLE,
|
||||
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
|
||||
)
|
||||
private val DEFAULT_DOCUMENT_PROJECTION = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
DocumentsContract.Document.COLUMN_FLAGS,
|
||||
DocumentsContract.Document.COLUMN_SIZE,
|
||||
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
|
||||
)
|
||||
|
||||
fun notifyRootsChanged(context: Context) {
|
||||
context.contentResolver.notifyChange(DocumentsContract.buildRootsUri(AUTHORITY), null)
|
||||
}
|
||||
}
|
||||
|
||||
private val usfExposeDelegate = AndroidUtils.LiveBooleanPreference("usf_expose", false)
|
||||
private val usfExpose by usfExposeDelegate
|
||||
private val usfSafWriteDelegate = AndroidUtils.LiveBooleanPreference("usf_saf_write", false)
|
||||
private val usfSafWrite by usfSafWriteDelegate
|
||||
private lateinit var volumeManager: VolumeManager
|
||||
private val volumes = HashMap<String, Pair<Int, VolumeData>>()
|
||||
private lateinit var encryptedFileProvider: EncryptedFileProvider
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
val context = (context ?: return false)
|
||||
AndroidUtils.LiveBooleanPreference.init(context, usfExposeDelegate, usfSafWriteDelegate)
|
||||
volumeManager = (context.applicationContext as VolumeManagerApp).volumeManager
|
||||
encryptedFileProvider = EncryptedFileProvider(context)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
|
||||
if (!usfExpose) return cursor
|
||||
volumes.clear()
|
||||
for (volume in volumeManager.listVolumes()) {
|
||||
var flags = DocumentsContract.Root.FLAG_LOCAL_ONLY or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
|
||||
if (usfSafWrite && volume.second.canWrite(context!!.filesDir.path)) {
|
||||
flags = flags or DocumentsContract.Root.FLAG_SUPPORTS_CREATE
|
||||
}
|
||||
cursor.newRow().apply {
|
||||
add(DocumentsContract.Root.COLUMN_ROOT_ID, volume.second.name)
|
||||
add(DocumentsContract.Root.COLUMN_FLAGS, flags)
|
||||
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.icon_document_provider)
|
||||
add(DocumentsContract.Root.COLUMN_TITLE, volume.second.name)
|
||||
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, volume.second.uuid)
|
||||
}
|
||||
volumes[volume.second.uuid] = volume
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
internal data class DocumentData(
|
||||
val rootId: String,
|
||||
val volumeId: Int,
|
||||
val volumeData: VolumeData,
|
||||
val encryptedVolume: EncryptedVolume,
|
||||
val path: String
|
||||
) {
|
||||
fun child(childPath: String) = DocumentData(rootId, volumeId, volumeData, encryptedVolume, childPath)
|
||||
}
|
||||
|
||||
private fun parseDocumentId(documentId: String): DocumentData? {
|
||||
val splits = documentId.split("/", limit = 2)
|
||||
if (splits.size > 2) {
|
||||
return null
|
||||
} else {
|
||||
volumes[splits[0]]?.let {
|
||||
val encryptedVolume = volumeManager.getVolume(it.first) ?: return null
|
||||
val path = "/"+if (splits.size == 2) {
|
||||
splits[1]
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return DocumentData(splits[0], it.first, it.second, encryptedVolume, path)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
|
||||
if (!usfExpose) return false
|
||||
val parent = parseDocumentId(parentDocumentId) ?: return false
|
||||
val child = parseDocumentId(documentId) ?: return false
|
||||
return parent.rootId == child.rootId && PathUtils.isChildOf(child.path, parent.path)
|
||||
}
|
||||
|
||||
private fun addDocumentRow(cursor: MatrixCursor, volumeData: VolumeData, documentId: String, name: String, stat: Stat) {
|
||||
val isDirectory = stat.type == Stat.S_IFDIR
|
||||
var flags = 0
|
||||
if (usfSafWrite && volumeData.canWrite(context!!.filesDir.path)) {
|
||||
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE or DocumentsContract.Document.FLAG_SUPPORTS_RENAME
|
||||
if (isDirectory) {
|
||||
flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
|
||||
} else if (stat.type == Stat.S_IFREG) {
|
||||
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE
|
||||
}
|
||||
}
|
||||
val mimeType = if (isDirectory) {
|
||||
DocumentsContract.Document.MIME_TYPE_DIR
|
||||
} else {
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(name).extension)
|
||||
?: "application/octet-stream"
|
||||
}
|
||||
cursor.newRow().apply {
|
||||
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId)
|
||||
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, name)
|
||||
add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType)
|
||||
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
|
||||
add(DocumentsContract.Document.COLUMN_SIZE, stat.size)
|
||||
add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, stat.mTime)
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
|
||||
if (!usfExpose) return cursor
|
||||
val document = parseDocumentId(documentId) ?: return cursor
|
||||
document.encryptedVolume.getAttr(document.path)?.let { stat ->
|
||||
val name = if (document.path == "/") {
|
||||
document.volumeData.shortName
|
||||
} else {
|
||||
File(document.path).name
|
||||
}
|
||||
addDocumentRow(cursor, document.volumeData, documentId, name, stat)
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
override fun queryChildDocuments(
|
||||
parentDocumentId: String,
|
||||
projection: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
|
||||
if (!usfExpose) return cursor
|
||||
val document = parseDocumentId(parentDocumentId) ?: return cursor
|
||||
document.encryptedVolume.readDir(document.path)?.let { content ->
|
||||
for (i in content) {
|
||||
if (i.isParentFolder) continue
|
||||
addDocumentRow(cursor, document.volumeData, document.rootId+i.fullPath, i.name, i.stat)
|
||||
}
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
class LazyExportedFile(
|
||||
private val encryptedFileProvider: EncryptedFileProvider,
|
||||
private val encryptedVolume: EncryptedVolume,
|
||||
path: String,
|
||||
) : EncryptedFileProvider.ExportedFile(path) {
|
||||
|
||||
private val exportedFile: EncryptedFileProvider.ExportedFile by lazy {
|
||||
val size = encryptedVolume.getAttr(path)?.size ?: run {
|
||||
Log.e(TAG, "stat() failed")
|
||||
throw RuntimeException("stat() failed")
|
||||
}
|
||||
val exportedFile = encryptedFileProvider.createFile(path, size) ?: run {
|
||||
Log.e(TAG, "Can't create exported file")
|
||||
throw RuntimeException("Can't create exported file")
|
||||
}
|
||||
if (!encryptedFileProvider.exportFile(exportedFile, encryptedVolume)) {
|
||||
Log.e(TAG, "File export failed")
|
||||
throw RuntimeException("File export failed")
|
||||
}
|
||||
exportedFile
|
||||
}
|
||||
|
||||
override fun open(mode: Int, furtive: Boolean) = exportedFile.open(mode, furtive)
|
||||
override fun free() = exportedFile.free()
|
||||
}
|
||||
|
||||
override fun openDocument(
|
||||
documentId: String,
|
||||
mode: String,
|
||||
signal: CancellationSignal?
|
||||
): ParcelFileDescriptor? {
|
||||
if (!usfExpose) return null
|
||||
val document = parseDocumentId(documentId) ?: return null
|
||||
|
||||
val lazyExportedFile = LazyExportedFile(encryptedFileProvider, document.encryptedVolume, document.path)
|
||||
|
||||
val result = encryptedFileProvider.openFile(
|
||||
lazyExportedFile,
|
||||
mode,
|
||||
document.encryptedVolume,
|
||||
volumeManager.getCoroutineScope(document.volumeId),
|
||||
true,
|
||||
usfSafWrite,
|
||||
)
|
||||
when (result.second) {
|
||||
EncryptedFileProvider.Error.SUCCESS -> return result.first!!
|
||||
EncryptedFileProvider.Error.WRITE_ACCESS_DENIED -> Log.e(TAG, "Unauthorized write access requested from $callingPackage")
|
||||
else -> result.second.log()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun createDocument(
|
||||
parentDocumentId: String,
|
||||
mimeType: String?,
|
||||
displayName: String
|
||||
): String? {
|
||||
if (!usfExpose || !usfSafWrite) return null
|
||||
val document = parseDocumentId(parentDocumentId) ?: return null
|
||||
val path = PathUtils.pathJoin(document.path, displayName)
|
||||
var success = false
|
||||
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
success = document.encryptedVolume.mkdir(path)
|
||||
} else {
|
||||
val f = document.encryptedVolume.openFileWriteMode(path)
|
||||
if (f != -1L) {
|
||||
document.encryptedVolume.closeFile(f)
|
||||
success = true
|
||||
}
|
||||
}
|
||||
return if (success) {
|
||||
document.rootId+path
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteDocument(documentId: String) {
|
||||
if (!usfExpose || !usfSafWrite) return
|
||||
|
||||
fun recursiveRemoveDirectory(document: DocumentData) {
|
||||
document.encryptedVolume.readDir(document.path)?.forEach { e ->
|
||||
val childPath = PathUtils.pathJoin(document.path, e.name)
|
||||
if (e.isDirectory) {
|
||||
recursiveRemoveDirectory(document.child(childPath))
|
||||
} else {
|
||||
document.encryptedVolume.deleteFile(childPath)
|
||||
}
|
||||
revokeDocumentPermission(document.rootId+childPath)
|
||||
}
|
||||
document.encryptedVolume.rmdir(document.path)
|
||||
}
|
||||
|
||||
val document = parseDocumentId(documentId) ?: return
|
||||
document.encryptedVolume.getAttr(document.path)?.let { stat ->
|
||||
if (stat.type == Stat.S_IFDIR) {
|
||||
recursiveRemoveDirectory(document)
|
||||
} else {
|
||||
document.encryptedVolume.deleteFile(document.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String, displayName: String): String {
|
||||
if (!usfExpose || !usfSafWrite) return documentId
|
||||
val document = parseDocumentId(documentId) ?: return documentId
|
||||
val newPath = PathUtils.pathJoin(PathUtils.getParentPath(document.path), displayName)
|
||||
return if (document.encryptedVolume.rename(document.path, newPath)) {
|
||||
document.rootId+newPath
|
||||
} else {
|
||||
documentId
|
||||
}
|
||||
}
|
||||
}
|
@ -1,55 +1,66 @@
|
||||
package sushi.hardcore.droidfs.explorers
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.AdapterView.OnItemClickListener
|
||||
import android.widget.AdapterView.OnItemLongClickListener
|
||||
import android.widget.EditText
|
||||
import android.widget.ListView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.activity.addCallback
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import sushi.hardcore.droidfs.BaseActivity
|
||||
import sushi.hardcore.droidfs.ConstValues
|
||||
import sushi.hardcore.droidfs.ConstValues.Companion.isAudio
|
||||
import sushi.hardcore.droidfs.ConstValues.Companion.isImage
|
||||
import sushi.hardcore.droidfs.ConstValues.Companion.isText
|
||||
import sushi.hardcore.droidfs.ConstValues.Companion.isVideo
|
||||
import sushi.hardcore.droidfs.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.EncryptedFileProvider
|
||||
import sushi.hardcore.droidfs.FileShare
|
||||
import sushi.hardcore.droidfs.FileTypes
|
||||
import sushi.hardcore.droidfs.LoadingTask
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.adapters.DialogSingleChoiceAdapter
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter
|
||||
import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
|
||||
import sushi.hardcore.droidfs.content_providers.ExternalProvider
|
||||
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
|
||||
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
|
||||
import sushi.hardcore.droidfs.file_operations.FileOperationService
|
||||
import sushi.hardcore.droidfs.file_operations.OperationFile
|
||||
import sushi.hardcore.droidfs.file_operations.TaskResult
|
||||
import sushi.hardcore.droidfs.file_viewers.AudioPlayer
|
||||
import sushi.hardcore.droidfs.file_viewers.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.Stat
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.util.UIUtils
|
||||
import sushi.hardcore.droidfs.util.finishOnClose
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||
|
||||
open class BaseExplorerActivity : BaseActivity() {
|
||||
open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listener {
|
||||
private lateinit var sortOrderEntries: Array<String>
|
||||
private lateinit var sortOrderValues: Array<String>
|
||||
private var foldersFirst = true
|
||||
private var mapFolders = true
|
||||
private var currentSortOrderIndex = 0
|
||||
protected lateinit var gocryptfsVolume: GocryptfsVolume
|
||||
protected var volumeId = -1
|
||||
protected lateinit var encryptedVolume: EncryptedVolume
|
||||
private lateinit var volumeName: String
|
||||
private lateinit var explorerViewModel: ExplorerViewModel
|
||||
protected var currentDirectoryPath: String = ""
|
||||
@ -58,51 +69,92 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
explorerViewModel.currentDirectoryPath = value
|
||||
}
|
||||
protected lateinit var fileOperationService: FileOperationService
|
||||
protected val activityScope = MainScope()
|
||||
private var directoryLoadingTask: Job? = null
|
||||
protected lateinit var explorerElements: MutableList<ExplorerElement>
|
||||
protected lateinit var explorerAdapter: ExplorerElementAdapter
|
||||
private var isCreating = true
|
||||
protected var isStartingActivity = false
|
||||
protected lateinit var app: VolumeManagerApp
|
||||
private var usf_open = false
|
||||
protected var usf_keep_open = false
|
||||
private lateinit var toolbar: androidx.appcompat.widget.Toolbar
|
||||
private lateinit var linearLayoutManager: LinearLayoutManager
|
||||
private var isUsingListLayout = true
|
||||
private lateinit var layoutIcon: ImageButton
|
||||
private lateinit var titleText: TextView
|
||||
private lateinit var listExplorer: ListView
|
||||
private lateinit var recycler_view_explorer: RecyclerView
|
||||
private lateinit var refresher: SwipeRefreshLayout
|
||||
private lateinit var loader: ProgressBar
|
||||
private lateinit var textDirEmpty: TextView
|
||||
private lateinit var currentPathText: TextView
|
||||
private lateinit var numberOfFilesText: TextView
|
||||
private lateinit var numberOfFoldersText: TextView
|
||||
private lateinit var totalSizeText: TextView
|
||||
protected val fileShare by lazy { FileShare(this) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
app = application as VolumeManagerApp
|
||||
usf_open = sharedPrefs.getBoolean("usf_open", false)
|
||||
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false)
|
||||
volumeName = intent.getStringExtra("volume_name") ?: ""
|
||||
val sessionID = intent.getIntExtra("sessionID", -1)
|
||||
gocryptfsVolume = GocryptfsVolume(sessionID)
|
||||
volumeName = intent.getStringExtra("volumeName") ?: ""
|
||||
volumeId = intent.getIntExtra("volumeId", -1)
|
||||
encryptedVolume = app.volumeManager.getVolume(volumeId)!!.also { finishOnClose(it) }
|
||||
sortOrderEntries = resources.getStringArray(R.array.sort_orders_entries)
|
||||
sortOrderValues = resources.getStringArray(R.array.sort_orders_values)
|
||||
foldersFirst = sharedPrefs.getBoolean("folders_first", 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()
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
titleText = findViewById(R.id.title_text)
|
||||
listExplorer = findViewById(R.id.list_explorer)
|
||||
recycler_view_explorer = findViewById(R.id.recycler_view_explorer)
|
||||
refresher = findViewById(R.id.refresher)
|
||||
loader = findViewById(R.id.loader)
|
||||
textDirEmpty = findViewById(R.id.text_dir_empty)
|
||||
currentPathText = findViewById(R.id.current_path_text)
|
||||
numberOfFilesText = findViewById(R.id.number_of_files_text)
|
||||
numberOfFoldersText = findViewById(R.id.number_of_folders_text)
|
||||
totalSizeText = findViewById(R.id.total_size_text)
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.apply {
|
||||
setDisplayShowCustomEnabled(true)
|
||||
setCustomView(R.layout.action_bar)
|
||||
titleText = customView.findViewById(R.id.title_text)
|
||||
}
|
||||
title = ""
|
||||
titleText.text = getString(R.string.volume, volumeName)
|
||||
explorerAdapter = ExplorerElementAdapter(this)
|
||||
explorerViewModel= ViewModelProvider(this).get(ExplorerViewModel::class.java)
|
||||
setVolumeNameTitle()
|
||||
explorerAdapter = ExplorerElementAdapter(
|
||||
this,
|
||||
if (sharedPrefs.getBoolean("thumbnails", true)) {
|
||||
encryptedVolume
|
||||
} else {
|
||||
null
|
||||
},
|
||||
this,
|
||||
sharedPrefs.getLong(Constants.THUMBNAIL_MAX_SIZE_KEY, Constants.DEFAULT_THUMBNAIL_MAX_SIZE)*1000,
|
||||
)
|
||||
explorerViewModel = ViewModelProvider(this).get(ExplorerViewModel::class.java)
|
||||
currentDirectoryPath = explorerViewModel.currentDirectoryPath
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
listExplorer.apply {
|
||||
adapter = explorerAdapter
|
||||
onItemClickListener = OnItemClickListener { _, _, position, _ -> onExplorerItemClick(position) }
|
||||
onItemLongClickListener = OnItemLongClickListener { _, _, position, _ -> onExplorerItemLongClick(position); true }
|
||||
linearLayoutManager = LinearLayoutManager(this@BaseExplorerActivity)
|
||||
recycler_view_explorer.adapter = explorerAdapter
|
||||
isUsingListLayout = sharedPrefs.getBoolean("useListLayout", true)
|
||||
layoutIcon = findViewById(R.id.layout_icon)
|
||||
setRecyclerViewLayout()
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
if (explorerAdapter.selectedItems.isEmpty()) {
|
||||
val parentPath = PathUtils.getParentPath(currentDirectoryPath)
|
||||
if (parentPath == currentDirectoryPath) {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
} else {
|
||||
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
|
||||
}
|
||||
} else {
|
||||
unselectAll()
|
||||
}
|
||||
}
|
||||
layoutIcon.setOnClickListener {
|
||||
isUsingListLayout = !isUsingListLayout
|
||||
setRecyclerViewLayout()
|
||||
recycler_view_explorer.recycledViewPool.clear()
|
||||
with (sharedPrefs.edit()) {
|
||||
putBoolean("useListLayout", isUsingListLayout)
|
||||
apply()
|
||||
}
|
||||
}
|
||||
refresher.setOnRefreshListener {
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
@ -112,54 +164,90 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
class ExplorerViewModel: ViewModel() {
|
||||
var currentDirectoryPath = ""
|
||||
var currentDirectoryPath = "/"
|
||||
}
|
||||
|
||||
private fun setRecyclerViewLayout() {
|
||||
layoutIcon.setImageResource(if (isUsingListLayout) {
|
||||
recycler_view_explorer.layoutManager = linearLayoutManager
|
||||
explorerAdapter.isUsingListLayout = true
|
||||
R.drawable.icon_view_grid
|
||||
} else {
|
||||
val displayMetrics = resources.displayMetrics
|
||||
val columnsNumber = (displayMetrics.widthPixels / displayMetrics.density / 200 + 0.5).toInt()
|
||||
recycler_view_explorer.layoutManager = GridLayoutManager(this, columnsNumber)
|
||||
explorerAdapter.isUsingListLayout = false
|
||||
R.drawable.icon_view_list
|
||||
})
|
||||
}
|
||||
|
||||
protected open fun init() {
|
||||
setContentView(R.layout.activity_explorer_base)
|
||||
setContentView(R.layout.activity_explorer)
|
||||
}
|
||||
|
||||
protected open fun bindFileOperationService(){
|
||||
Intent(this, FileOperationService::class.java).also {
|
||||
bindService(it, object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as FileOperationService.LocalBinder
|
||||
fileOperationService = binder.getService()
|
||||
binder.setGocryptfsVolume(gocryptfsVolume)
|
||||
}
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
|
||||
}
|
||||
}, Context.BIND_AUTO_CREATE)
|
||||
protected open fun bindFileOperationService() {
|
||||
FileOperationService.bind(this) {
|
||||
fileOperationService = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFileViewer(cls: Class<*>, filePath: String){
|
||||
private fun startFileViewer(cls: Class<*>, filePath: String) {
|
||||
val intent = Intent(this, cls).apply {
|
||||
putExtra("path", filePath)
|
||||
putExtra("sessionID", gocryptfsVolume.sessionID)
|
||||
putExtra("volumeId", volumeId)
|
||||
putExtra("sortOrder", sortOrderValues[currentSortOrderIndex])
|
||||
}
|
||||
isStartingActivity = true
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun openWithExternalApp(fullPath: String){
|
||||
isStartingActivity = true
|
||||
ExternalProvider.open(this, gocryptfsVolume, fullPath)
|
||||
protected fun onExportFailed(errorResId: Int) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.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)
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setSingleChoiceItems(adapter, -1) { dialog, which ->
|
||||
when (adapter.getItem(which)) {
|
||||
"image" -> startFileViewer(ImageViewer::class.java, path)
|
||||
"video" -> startFileViewer(VideoPlayer::class.java, path)
|
||||
"audio" -> startFileViewer(AudioPlayer::class.java, path)
|
||||
"pdf" -> startFileViewer(PdfViewer::class.java, path)
|
||||
"text" -> startFileViewer(TextEditor::class.java, path)
|
||||
"external" -> if (usf_open) {
|
||||
openWithExternalApp(path)
|
||||
openWithExternalApp(path, explorerElement.stat.size)
|
||||
}
|
||||
}
|
||||
dialog.dismiss()
|
||||
@ -169,131 +257,170 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
.show()
|
||||
}
|
||||
|
||||
protected open fun onExplorerItemClick(position: Int) {
|
||||
val wasSelecting = explorerAdapter.selectedItems.isNotEmpty()
|
||||
explorerAdapter.onItemClick(position)
|
||||
if (explorerAdapter.selectedItems.isEmpty()) {
|
||||
if (!wasSelecting) {
|
||||
val fullPath = explorerElements[position].fullPath
|
||||
when {
|
||||
explorerElements[position].isDirectory -> {
|
||||
setCurrentPath(fullPath)
|
||||
}
|
||||
explorerElements[position].isParentFolder -> {
|
||||
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
|
||||
}
|
||||
isImage(fullPath) -> {
|
||||
startFileViewer(ImageViewer::class.java, fullPath)
|
||||
}
|
||||
isVideo(fullPath) -> {
|
||||
startFileViewer(VideoPlayer::class.java, fullPath)
|
||||
}
|
||||
isText(fullPath) -> {
|
||||
startFileViewer(TextEditor::class.java, fullPath)
|
||||
}
|
||||
isAudio(fullPath) -> {
|
||||
startFileViewer(AudioPlayer::class.java, fullPath)
|
||||
}
|
||||
else -> showOpenAsDialog(fullPath)
|
||||
protected fun createNewFile(callback: (Long) -> Unit) {
|
||||
EditTextDialog(this, R.string.enter_file_name) {
|
||||
if (it.isEmpty()) {
|
||||
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
|
||||
createNewFile(callback)
|
||||
} else {
|
||||
val filePath = PathUtils.pathJoin(currentDirectoryPath, it)
|
||||
val handleID = encryptedVolume.openFileWriteMode(filePath)
|
||||
if (handleID == -1L) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.file_creation_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
callback(handleID)
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun setVolumeNameTitle() {
|
||||
titleText.text = getString(R.string.volume, volumeName)
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(size: Int) {
|
||||
if (size == 0) {
|
||||
setVolumeNameTitle()
|
||||
} else {
|
||||
titleText.text = getString(R.string.elements_selected, size, explorerElements.count { !it.isParentFolder })
|
||||
}
|
||||
}
|
||||
|
||||
override fun onExplorerElementClick(position: Int) {
|
||||
if (explorerAdapter.selectedItems.isEmpty()) {
|
||||
val fullPath = explorerElements[position].fullPath
|
||||
when {
|
||||
explorerElements[position].isDirectory -> {
|
||||
setCurrentPath(fullPath)
|
||||
}
|
||||
explorerElements[position].isParentFolder -> {
|
||||
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
|
||||
}
|
||||
FileTypes.isImage(fullPath) -> {
|
||||
startFileViewer(ImageViewer::class.java, fullPath)
|
||||
}
|
||||
FileTypes.isVideo(fullPath) -> {
|
||||
startFileViewer(VideoPlayer::class.java, fullPath)
|
||||
}
|
||||
FileTypes.isText(fullPath) -> {
|
||||
startFileViewer(TextEditor::class.java, fullPath)
|
||||
}
|
||||
FileTypes.isPDF(fullPath) -> {
|
||||
startFileViewer(PdfViewer::class.java, fullPath)
|
||||
}
|
||||
FileTypes.isAudio(fullPath) -> {
|
||||
startFileViewer(AudioPlayer::class.java, fullPath)
|
||||
}
|
||||
else -> showOpenAsDialog(explorerElements[position])
|
||||
}
|
||||
}
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
protected open fun onExplorerItemLongClick(position: Int) {
|
||||
explorerAdapter.onItemLongClick(position)
|
||||
override fun onExplorerElementLongClick(position: Int) {
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
protected fun unselectAll(){
|
||||
explorerAdapter.unSelectAll()
|
||||
protected fun unselectAll(notifyChange: Boolean = true) {
|
||||
explorerAdapter.unSelectAll(notifyChange)
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
private fun sortExplorerElements() {
|
||||
private fun displayExplorerElements() {
|
||||
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
|
||||
val sharedPrefsEditor = sharedPrefs.edit()
|
||||
sharedPrefsEditor.putString(ConstValues.sort_order_key, sortOrderValues[currentSortOrderIndex])
|
||||
sharedPrefsEditor.apply()
|
||||
unselectAll(false)
|
||||
loader.isVisible = false
|
||||
recycler_view_explorer.isVisible = true
|
||||
explorerAdapter.explorerElements = explorerElements
|
||||
}
|
||||
|
||||
protected fun setCurrentPath(path: String) {
|
||||
synchronized(this) {
|
||||
explorerElements = gocryptfsVolume.listDir(path)
|
||||
private suspend fun recursiveSetSize(directory: ExplorerElement) {
|
||||
yield()
|
||||
for (child in encryptedVolume.readDir(directory.fullPath) ?: return) {
|
||||
if (child.isDirectory) {
|
||||
recursiveSetSize(child)
|
||||
}
|
||||
directory.stat.size += child.stat.size
|
||||
}
|
||||
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.INVISIBLE
|
||||
}
|
||||
|
||||
private fun displayNumberOfElements(textView: TextView, stringIdSingular: Int, stringIdPlural: Int, count: Int) {
|
||||
with(textView) {
|
||||
visibility = if (count == 0) {
|
||||
View.GONE
|
||||
} else {
|
||||
text = if (count == 1) {
|
||||
getString(stringIdSingular)
|
||||
} else {
|
||||
getString(stringIdPlural, count)
|
||||
}
|
||||
View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) = lifecycleScope.launch {
|
||||
directoryLoadingTask?.cancelAndJoin()
|
||||
recycler_view_explorer.isVisible = false
|
||||
loader.isVisible = true
|
||||
explorerElements = encryptedVolume.readDir(path) ?: return@launch
|
||||
if (path != "/") {
|
||||
explorerElements.add(
|
||||
0,
|
||||
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
|
||||
)
|
||||
}
|
||||
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.GONE
|
||||
currentDirectoryPath = path
|
||||
currentPathText.text = getString(R.string.location, currentDirectoryPath)
|
||||
Thread{
|
||||
val totalSizeValue = if (mapFolders) {
|
||||
var totalSize: Long = 0
|
||||
synchronized(this) {
|
||||
for (element in explorerElements){
|
||||
if (element.isDirectory){
|
||||
var dirSize: Long = 0
|
||||
for (subFile in gocryptfsVolume.recursiveMapFiles(element.fullPath)){
|
||||
if (subFile.isRegularFile){
|
||||
dirSize += subFile.size
|
||||
}
|
||||
}
|
||||
element.size = dirSize
|
||||
totalSize += dirSize
|
||||
} else if (element.isRegularFile) {
|
||||
totalSize += element.size
|
||||
}
|
||||
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 })
|
||||
if (mapFolders) {
|
||||
var totalSize: Long = 0
|
||||
directoryLoadingTask = launch(Dispatchers.IO) {
|
||||
for (element in explorerElements) {
|
||||
if (element.isDirectory) {
|
||||
recursiveSetSize(element)
|
||||
}
|
||||
totalSize += element.stat.size
|
||||
}
|
||||
PathUtils.formatSize(totalSize)
|
||||
} else {
|
||||
getString(R.string.default_total_size)
|
||||
}
|
||||
runOnUiThread {
|
||||
totalSizeText.text = getString(R.string.total_size, totalSizeValue)
|
||||
synchronized(this) {
|
||||
sortExplorerElements()
|
||||
}
|
||||
if (path.isNotEmpty()) { //not root
|
||||
synchronized(this) {
|
||||
explorerElements.add(
|
||||
0,
|
||||
ExplorerElement("..", (-1).toShort(), parentPath = currentDirectoryPath)
|
||||
)
|
||||
}
|
||||
}
|
||||
explorerAdapter.setExplorerElements(explorerElements)
|
||||
}
|
||||
}.start()
|
||||
directoryLoadingTask!!.join()
|
||||
displayExplorerElements()
|
||||
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
|
||||
onDisplayed?.invoke()
|
||||
} else {
|
||||
displayExplorerElements()
|
||||
totalSizeText.text = getString(
|
||||
R.string.total_size,
|
||||
PathUtils.formatSize(explorerElements.filter { !it.isParentFolder }.sumOf { it.stat.size })
|
||||
)
|
||||
onDisplayed?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
private fun askCloseVolume() {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
private fun askLockVolume() {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.ask_close_volume)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> closeVolumeOnUserExit() }
|
||||
.setMessage(R.string.ask_lock_volume)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
app.volumeManager.closeVolume(volumeId)
|
||||
finish()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.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){
|
||||
if (folderName.isEmpty()) {
|
||||
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
if (!gocryptfsVolume.mkdir(PathUtils.pathJoin(currentDirectoryPath, folderName))) {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
if (!encryptedVolume.mkdir(PathUtils.pathJoin(currentDirectoryPath, folderName))) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.error_mkdir)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
@ -306,85 +433,62 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
}
|
||||
|
||||
protected fun openDialogCreateFolder() {
|
||||
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null)
|
||||
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text)
|
||||
val dialog = ColoredAlertDialogBuilder(this)
|
||||
.setView(dialogEditTextView)
|
||||
.setTitle(R.string.enter_folder_name)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
val folderName = dialogEditText.text.toString()
|
||||
createFolder(folderName)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create()
|
||||
dialogEditText.setOnEditorActionListener { _, _, _ ->
|
||||
val folderName = dialogEditText.text.toString()
|
||||
dialog.dismiss()
|
||||
createFolder(folderName)
|
||||
true
|
||||
}
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
dialog.show()
|
||||
EditTextDialog(this, R.string.enter_folder_name) {
|
||||
createFolder(it)
|
||||
}.show()
|
||||
}
|
||||
|
||||
protected fun checkPathOverwrite(items: ArrayList<OperationFile>, dstDirectoryPath: String, callback: (ArrayList<OperationFile>?) -> Unit) {
|
||||
val srcDirectoryPath = items[0].explorerElement.parentPath
|
||||
protected fun checkPathOverwrite(items: List<OperationFile>, dstDirectoryPath: String, callback: (List<OperationFile>?) -> Unit) {
|
||||
val srcDirectoryPath = items[0].parentPath
|
||||
var ready = true
|
||||
for (i in 0 until items.size) {
|
||||
for (i in items.indices) {
|
||||
val testDstPath: String
|
||||
if (items[i].dstPath == null){
|
||||
testDstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].explorerElement.fullPath))
|
||||
if (gocryptfsVolume.pathExists(testDstPath)){
|
||||
testDstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].srcPath))
|
||||
if (encryptedVolume.pathExists(testDstPath)) {
|
||||
ready = false
|
||||
} else {
|
||||
items[i].dstPath = testDstPath
|
||||
}
|
||||
} else {
|
||||
testDstPath = items[i].dstPath!!
|
||||
if (gocryptfsVolume.pathExists(testDstPath) && !items[i].overwriteConfirmed){
|
||||
if (encryptedVolume.pathExists(testDstPath) && !items[i].overwriteConfirmed) {
|
||||
ready = false
|
||||
}
|
||||
}
|
||||
if (!ready){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(getString(if (items[i].explorerElement.isDirectory){R.string.dir_overwrite_question} else {R.string.file_overwrite_question}, testDstPath))
|
||||
.setMessage(getString(
|
||||
if (items[i].isDirectory) {
|
||||
R.string.dir_overwrite_question
|
||||
} else {
|
||||
R.string.file_overwrite_question
|
||||
}, testDstPath
|
||||
))
|
||||
.setPositiveButton(R.string.yes) {_, _ ->
|
||||
items[i].dstPath = testDstPath
|
||||
items[i].overwriteConfirmed = true
|
||||
checkPathOverwrite(items, dstDirectoryPath, callback)
|
||||
}
|
||||
.setNegativeButton(R.string.no) { _, _ ->
|
||||
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null)
|
||||
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text)
|
||||
dialogEditText.setText(items[i].explorerElement.name)
|
||||
dialogEditText.selectAll()
|
||||
val dialog = ColoredAlertDialogBuilder(this)
|
||||
.setView(dialogEditTextView)
|
||||
.setTitle(R.string.enter_new_name)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].explorerElement.parentPath), dialogEditText.text.toString())
|
||||
if (items[i].explorerElement.isDirectory){
|
||||
for (j in 0 until items.size){
|
||||
if (PathUtils.isChildOf(items[j].explorerElement.fullPath, items[i].explorerElement.fullPath)){
|
||||
items[j].dstPath = PathUtils.pathJoin(items[i].dstPath!!, PathUtils.getRelativePath(items[i].explorerElement.fullPath, items[j].explorerElement.fullPath))
|
||||
}
|
||||
}
|
||||
with(EditTextDialog(this, R.string.enter_new_name) {
|
||||
items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].parentPath), it)
|
||||
if (items[i].isDirectory) {
|
||||
for (j in items.indices) {
|
||||
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))
|
||||
}
|
||||
checkPathOverwrite(items, dstDirectoryPath, callback)
|
||||
}
|
||||
.setOnCancelListener{
|
||||
callback(null)
|
||||
}
|
||||
.create()
|
||||
dialogEditText.setOnEditorActionListener { _, _, _ ->
|
||||
dialog.dismiss()
|
||||
items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].explorerElement.parentPath), dialogEditText.text.toString())
|
||||
}
|
||||
checkPathOverwrite(items, dstDirectoryPath, callback)
|
||||
true
|
||||
}) {
|
||||
setSelectedText(items[i].name)
|
||||
setOnCancelListener{
|
||||
callback(null)
|
||||
}
|
||||
show()
|
||||
}
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
dialog.show()
|
||||
}
|
||||
.setOnCancelListener{
|
||||
callback(null)
|
||||
@ -398,12 +502,38 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
protected fun importFilesFromUris(uris: List<Uri>, callback: (String?) -> Unit) {
|
||||
protected fun onTaskResult(
|
||||
result: TaskResult<out String?>,
|
||||
failedErrorMessage: Int,
|
||||
successMessage: Int = -1,
|
||||
onSuccess: (() -> Unit)? = null,
|
||||
) {
|
||||
when (result.state) {
|
||||
TaskResult.State.SUCCESS -> {
|
||||
if (onSuccess == null) {
|
||||
Toast.makeText(this, successMessage, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
TaskResult.State.FAILED -> {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(failedErrorMessage, result.failedItem))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
TaskResult.State.ERROR -> result.showErrorAlertDialog(this, theme)
|
||||
TaskResult.State.CANCELLED -> {}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun importFilesFromUris(uris: List<Uri>, callback: () -> Unit) {
|
||||
val items = ArrayList<OperationFile>()
|
||||
for (uri in uris) {
|
||||
val fileName = PathUtils.getFilenameFromURI(this, uri)
|
||||
if (fileName == null) {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.error_retrieving_filename, uri))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
@ -411,30 +541,16 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
items.clear()
|
||||
break
|
||||
} else {
|
||||
items.add(OperationFile.fromExplorerElement(ExplorerElement(fileName, 1, parentPath = currentDirectoryPath)))
|
||||
items.add(OperationFile(PathUtils.pathJoin(currentDirectoryPath, fileName), Stat.S_IFREG))
|
||||
}
|
||||
}
|
||||
if (items.size > 0) {
|
||||
checkPathOverwrite(items, currentDirectoryPath) { checkedItems ->
|
||||
checkedItems?.let {
|
||||
fileOperationService.importFilesFromUris(checkedItems.map { it.dstPath!! }, uris){ failedItem ->
|
||||
runOnUiThread {
|
||||
callback(failedItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun importDirectory(sourceUri: Uri, callback: (String?, List<Uri>, DocumentFile) -> Unit) {
|
||||
val tree = DocumentFile.fromTreeUri(this, sourceUri)!! //non-null after Lollipop
|
||||
val operation = OperationFile.fromExplorerElement(ExplorerElement(tree.name!!, 0, parentPath = currentDirectoryPath))
|
||||
checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation ->
|
||||
checkedOperation?.let {
|
||||
fileOperationService.importDirectory(checkedOperation[0].dstPath!!, tree) { failedItem, uris ->
|
||||
runOnUiThread {
|
||||
callback(failedItem, uris, tree)
|
||||
activityScope.launch {
|
||||
val result = fileOperationService.importFilesFromUris(volumeId, checkedItems.map { it.dstPath!! }, uris)
|
||||
onTaskResult(result, R.string.import_failed, onSuccess = callback)
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -445,35 +561,39 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
if (new_name.isEmpty()) {
|
||||
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
if (!gocryptfsVolume.rename(PathUtils.pathJoin(currentDirectoryPath, old_name), PathUtils.pathJoin(currentDirectoryPath, new_name))) {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
if (!encryptedVolume.rename(PathUtils.pathJoin(currentDirectoryPath, old_name), PathUtils.pathJoin(currentDirectoryPath, new_name))) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.rename_failed, old_name))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
invalidateOptionsMenu()
|
||||
setCurrentPath(currentDirectoryPath) {
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun handleMenuItems(menu: Menu){
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menu.findItem(R.id.rename).isVisible = false
|
||||
menu.findItem(R.id.open_as)?.isVisible = false
|
||||
if (usf_open){
|
||||
menu.findItem(R.id.external_open)?.isVisible = false
|
||||
}
|
||||
val noItemSelected = explorerAdapter.selectedItems.isEmpty()
|
||||
with(UIUtils.getMenuIconNeutralTint(this, menu)) {
|
||||
applyTo(R.id.sort, R.drawable.icon_sort)
|
||||
applyTo(R.id.share, R.drawable.icon_share)
|
||||
}
|
||||
menu.findItem(R.id.sort).isVisible = noItemSelected
|
||||
menu.findItem(R.id.lock).isVisible = noItemSelected
|
||||
menu.findItem(R.id.close).isVisible = noItemSelected
|
||||
if (noItemSelected){
|
||||
toolbar.navigationIcon = null
|
||||
} else {
|
||||
toolbar.setNavigationIcon(R.drawable.icon_arrow_back)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(!noItemSelected)
|
||||
if (!noItemSelected) {
|
||||
if (explorerAdapter.selectedItems.size == 1) {
|
||||
menu.findItem(R.id.rename).isVisible = true
|
||||
if (explorerElements[explorerAdapter.selectedItems[0]].isRegularFile) {
|
||||
if (explorerElements[explorerAdapter.selectedItems.first()].isRegularFile) {
|
||||
menu.findItem(R.id.open_as)?.isVisible = true
|
||||
if (usf_open) {
|
||||
menu.findItem(R.id.external_open)?.isVisible = true
|
||||
@ -481,6 +601,7 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
@ -490,11 +611,17 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
true
|
||||
}
|
||||
R.id.sort -> {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.sort_order)
|
||||
.setSingleChoiceItems(DialogSingleChoiceAdapter(this, sortOrderEntries.toList()), currentSortOrderIndex) { dialog, which ->
|
||||
.setSingleChoiceItems(sortOrderEntries, currentSortOrderIndex) { dialog, 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()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
@ -502,90 +629,51 @@ open class BaseExplorerActivity : BaseActivity() {
|
||||
true
|
||||
}
|
||||
R.id.rename -> {
|
||||
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null)
|
||||
val oldName = explorerElements[explorerAdapter.selectedItems[0]].name
|
||||
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text)
|
||||
dialogEditText.setText(oldName)
|
||||
dialogEditText.selectAll()
|
||||
val dialog = ColoredAlertDialogBuilder(this)
|
||||
.setView(dialogEditTextView)
|
||||
.setTitle(R.string.rename_title)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
val newName = dialogEditText.text.toString()
|
||||
rename(oldName, newName)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create()
|
||||
dialogEditText.setOnEditorActionListener { _, _, _ ->
|
||||
val newName = dialogEditText.text.toString()
|
||||
dialog.dismiss()
|
||||
rename(oldName, newName)
|
||||
true
|
||||
val oldName = explorerElements[explorerAdapter.selectedItems.first()].name
|
||||
with(EditTextDialog(this, R.string.rename_title) {
|
||||
rename(oldName, it)
|
||||
}) {
|
||||
setSelectedText(oldName)
|
||||
show()
|
||||
}
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
dialog.show()
|
||||
true
|
||||
}
|
||||
R.id.open_as -> {
|
||||
showOpenAsDialog(PathUtils.pathJoin(currentDirectoryPath, explorerElements[explorerAdapter.selectedItems[0]].name))
|
||||
showOpenAsDialog(explorerElements[explorerAdapter.selectedItems.first()])
|
||||
true
|
||||
}
|
||||
R.id.external_open -> {
|
||||
if (usf_open){
|
||||
openWithExternalApp(PathUtils.pathJoin(currentDirectoryPath, explorerElements[explorerAdapter.selectedItems[0]].name))
|
||||
val explorerElement = explorerElements[explorerAdapter.selectedItems.first()]
|
||||
openWithExternalApp(explorerElement.fullPath, explorerElement.stat.size)
|
||||
unselectAll()
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.close -> {
|
||||
askCloseVolume()
|
||||
finish()
|
||||
true
|
||||
}
|
||||
R.id.lock -> {
|
||||
askLockVolume()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun closeVolumeOnUserExit() {
|
||||
finish()
|
||||
}
|
||||
|
||||
protected open fun closeVolumeOnDestroy() {
|
||||
if (!gocryptfsVolume.isClosed()){
|
||||
gocryptfsVolume.close()
|
||||
}
|
||||
RestrictedFileProvider.wipeAll(this) //additional security
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (!isChangingConfigurations) { //activity won't be recreated
|
||||
closeVolumeOnDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (!isChangingConfigurations){
|
||||
if (isStartingActivity){
|
||||
isStartingActivity = false
|
||||
} else if (!usf_keep_open){
|
||||
finish()
|
||||
}
|
||||
activityScope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (isCreating){
|
||||
isCreating = false
|
||||
} else {
|
||||
if (gocryptfsVolume.isClosed()){
|
||||
finish()
|
||||
} else {
|
||||
isStartingActivity = false
|
||||
ExternalProvider.removeFiles(this)
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
if (app.isStartingExternalApp) {
|
||||
TemporaryFileProvider.instance.wipe()
|
||||
}
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
|
@ -5,22 +5,23 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import android.widget.Toast
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.CameraActivity
|
||||
import sushi.hardcore.droidfs.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.OpenActivity
|
||||
import sushi.hardcore.droidfs.LoadingTask
|
||||
import sushi.hardcore.droidfs.MainActivity
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.adapters.IconTextDialogAdapter
|
||||
import sushi.hardcore.droidfs.content_providers.ExternalProvider
|
||||
import sushi.hardcore.droidfs.databinding.ActivityExplorerBinding
|
||||
import sushi.hardcore.droidfs.file_operations.OperationFile
|
||||
import sushi.hardcore.droidfs.filesystems.Stat
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import java.io.File
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||
|
||||
class ExplorerActivity : BaseExplorerActivity() {
|
||||
companion object {
|
||||
@ -31,186 +32,171 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
private var usf_share = false
|
||||
private var currentItemAction = ItemsActions.NONE
|
||||
private val itemsToProcess = ArrayList<OperationFile>()
|
||||
private lateinit var binding: ActivityExplorerBinding
|
||||
private val pickFromOtherVolumes = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
result.data?.let { resultIntent ->
|
||||
val remoteSessionID = resultIntent.getIntExtra("sessionID", -1)
|
||||
val remoteGocryptfsVolume = GocryptfsVolume(remoteSessionID)
|
||||
val srcVolumeId = resultIntent.getIntExtra("volumeId", -1)
|
||||
val srcEncryptedVolume = app.volumeManager.getVolume(srcVolumeId)!!
|
||||
val path = resultIntent.getStringExtra("path")
|
||||
val operationFiles = ArrayList<OperationFile>()
|
||||
if (path == null){ //multiples elements
|
||||
val paths = resultIntent.getStringArrayListExtra("paths")
|
||||
val types = resultIntent.getIntegerArrayListExtra("types")
|
||||
if (types != null && paths != null){
|
||||
for (i in paths.indices) {
|
||||
operationFiles.add(
|
||||
OperationFile.fromExplorerElement(
|
||||
ExplorerElement(File(paths[i]).name, types[i].toShort(), parentPath = PathUtils.getParentPath(paths[i]))
|
||||
)
|
||||
)
|
||||
if (types[i] == 0){ //directory
|
||||
remoteGocryptfsVolume.recursiveMapFiles(paths[i]).forEach {
|
||||
operationFiles.add(OperationFile.fromExplorerElement(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
operationFiles.add(
|
||||
OperationFile.fromExplorerElement(
|
||||
ExplorerElement(File(path).name, 1, parentPath = PathUtils.getParentPath(path))
|
||||
)
|
||||
)
|
||||
}
|
||||
if (operationFiles.size > 0){
|
||||
checkPathOverwrite(operationFiles, currentDirectoryPath) { items ->
|
||||
if (items == null) {
|
||||
remoteGocryptfsVolume.close()
|
||||
} else {
|
||||
fileOperationService.copyElements(items, remoteGocryptfsVolume){ failedItem ->
|
||||
runOnUiThread {
|
||||
if (failedItem == null){
|
||||
Toast.makeText(this, R.string.success_import, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.import_failed, failedItem))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
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) {
|
||||
operationFiles.add(OperationFile(paths[i], types[i]))
|
||||
if (types[i] == Stat.S_IFDIR) {
|
||||
srcEncryptedVolume.recursiveMapFiles(paths[i])?.forEach {
|
||||
operationFiles.add(OperationFile.fromExplorerElement(it))
|
||||
}
|
||||
}
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
remoteGocryptfsVolume.close()
|
||||
return operationFiles
|
||||
}
|
||||
}.startTask(lifecycleScope) { operationFiles ->
|
||||
importFilesFromVolume(srcVolumeId, operationFiles)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
remoteGocryptfsVolume.close()
|
||||
importFilesFromVolume(srcVolumeId, arrayListOf(OperationFile(path, Stat.S_IFREG)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||
if (uris != null) {
|
||||
importFilesFromUris(uris){ failedItem ->
|
||||
onImportComplete(failedItem, uris)
|
||||
for (uri in uris) {
|
||||
try {
|
||||
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
} catch (e: SecurityException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
importFilesFromUris(uris) {
|
||||
onImportComplete(uris)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val pickExportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
fileOperationService.exportFiles(uri, explorerAdapter.selectedItems.map { i -> explorerElements[i] }){ failedItem ->
|
||||
runOnUiThread {
|
||||
if (failedItem == null){
|
||||
Toast.makeText(this, R.string.success_export, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.export_failed, failedItem))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
|
||||
activityScope.launch {
|
||||
val result = fileOperationService.exportFiles(volumeId, items, uri)
|
||||
onTaskResult(result, R.string.export_failed, R.string.success_export)
|
||||
}
|
||||
}
|
||||
unselectAll()
|
||||
}
|
||||
private val pickImportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { rootUri ->
|
||||
rootUri?.let {
|
||||
importDirectory(it, ::onImportComplete)
|
||||
contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val tree = DocumentFile.fromTreeUri(this, it)!! //non-null after Lollipop
|
||||
val operation = OperationFile(PathUtils.pathJoin(currentDirectoryPath, tree.name!!), Stat.S_IFDIR)
|
||||
checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation ->
|
||||
checkedOperation?.let {
|
||||
activityScope.launch {
|
||||
val result = fileOperationService.importDirectory(volumeId, checkedOperation[0].dstPath!!, tree)
|
||||
onTaskResult(result.taskResult, R.string.import_failed) {
|
||||
onImportComplete(result.uris, tree)
|
||||
}
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onImportComplete(failedItem: String?, urisToWipe: List<Uri>, rootFile: DocumentFile? = null) {
|
||||
if (failedItem == null){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.success_import)
|
||||
.setMessage("""
|
||||
${getString(R.string.success_import_msg)}
|
||||
${getString(R.string.ask_for_wipe)}
|
||||
""".trimIndent())
|
||||
.setPositiveButton(R.string.yes) { _, _ ->
|
||||
fileOperationService.wipeUris(urisToWipe, rootFile) { errorMsg ->
|
||||
runOnUiThread {
|
||||
if (errorMsg == null){
|
||||
Toast.makeText(this, R.string.wipe_successful, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.wipe_failed, errorMsg))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun importFilesFromVolume(srcVolumeId: Int, operationFiles: List<OperationFile>) {
|
||||
checkPathOverwrite(operationFiles, currentDirectoryPath) { items ->
|
||||
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)
|
||||
}
|
||||
.setNegativeButton(R.string.no, null)
|
||||
.show()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.import_failed, failedItem))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
|
||||
}
|
||||
|
||||
private fun onImportComplete(urisToWipe: List<Uri>, rootFile: DocumentFile? = null) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.success_import)
|
||||
.setMessage("""
|
||||
${getString(R.string.success_import_msg)}
|
||||
${getString(R.string.ask_for_wipe)}
|
||||
""".trimIndent())
|
||||
.setPositiveButton(R.string.yes) { _, _ ->
|
||||
activityScope.launch {
|
||||
onTaskResult(
|
||||
fileOperationService.wipeUris(urisToWipe, rootFile),
|
||||
R.string.wipe_failed,
|
||||
R.string.wipe_successful,
|
||||
)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.no, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
binding = ActivityExplorerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
binding.fab.setOnClickListener {
|
||||
super.init()
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
if (currentItemAction != ItemsActions.NONE) {
|
||||
cancelItemAction()
|
||||
invalidateOptionsMenu()
|
||||
} else {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
isEnabled = true
|
||||
}
|
||||
}
|
||||
findViewById<FloatingActionButton>(R.id.fab).setOnClickListener {
|
||||
if (currentItemAction != ItemsActions.NONE){
|
||||
openDialogCreateFolder()
|
||||
} else {
|
||||
val adapter = IconTextDialogAdapter(this)
|
||||
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("importFolder", R.string.import_folder, R.drawable.icon_import_folder),
|
||||
listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown),
|
||||
listOf("createFolder", R.string.mkdir, R.drawable.icon_create_new_folder),
|
||||
listOf("camera", R.string.camera, R.drawable.icon_camera)
|
||||
listOf("camera", R.string.camera, R.drawable.icon_photo)
|
||||
)
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setSingleChoiceItems(adapter, -1){ thisDialog, which ->
|
||||
when (adapter.getItem(which)){
|
||||
"importFromOtherVolumes" -> {
|
||||
val intent = Intent(this, OpenActivity::class.java)
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.action = "pick"
|
||||
intent.putExtra("sessionID", gocryptfsVolume.sessionID)
|
||||
isStartingActivity = true
|
||||
pickFromOtherVolumes.launch(intent)
|
||||
}
|
||||
"importFiles" -> {
|
||||
isStartingActivity = true
|
||||
app.isStartingExternalApp = true
|
||||
pickFiles.launch(arrayOf("*/*"))
|
||||
}
|
||||
"importFolder" -> {
|
||||
isStartingActivity = true
|
||||
app.isStartingExternalApp = true
|
||||
pickImportDirectory.launch(null)
|
||||
}
|
||||
"createFile" -> {
|
||||
val dialogEditTextView = layoutInflater.inflate(R.layout.dialog_edit_text, null)
|
||||
val dialogEditText = dialogEditTextView.findViewById<EditText>(R.id.dialog_edit_text)
|
||||
val dialog = ColoredAlertDialogBuilder(this)
|
||||
.setView(dialogEditTextView)
|
||||
.setTitle(getString(R.string.enter_file_name))
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
val fileName = dialogEditText.text.toString()
|
||||
createNewFile(fileName)
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create()
|
||||
dialogEditText.setOnEditorActionListener { _, _, _ ->
|
||||
val fileName = dialogEditText.text.toString()
|
||||
dialog.dismiss()
|
||||
createNewFile(fileName)
|
||||
true
|
||||
createNewFile {
|
||||
encryptedVolume.closeFile(it)
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
dialog.show()
|
||||
}
|
||||
"createFolder" -> {
|
||||
openDialogCreateFolder()
|
||||
@ -218,14 +204,13 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
"camera" -> {
|
||||
val intent = Intent(this, CameraActivity::class.java)
|
||||
intent.putExtra("path", currentDirectoryPath)
|
||||
intent.putExtra("sessionID", gocryptfsVolume.sessionID)
|
||||
isStartingActivity = true
|
||||
intent.putExtra("volumeId", volumeId)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
thisDialog.dismiss()
|
||||
}
|
||||
.setTitle(getString(R.string.fab_dialog_title))
|
||||
.setTitle(getString(R.string.add))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
@ -234,39 +219,20 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
usf_share = sharedPrefs.getBoolean("usf_share", false)
|
||||
}
|
||||
|
||||
override fun onExplorerItemLongClick(position: Int) {
|
||||
override fun onExplorerElementLongClick(position: Int) {
|
||||
super.onExplorerElementLongClick(position)
|
||||
cancelItemAction()
|
||||
explorerAdapter.onItemLongClick(position)
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
private fun createNewFile(fileName: String){
|
||||
if (fileName.isEmpty()) {
|
||||
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
val filePath = PathUtils.pathJoin(currentDirectoryPath, fileName)
|
||||
val handleID = gocryptfsVolume.openWriteMode(filePath) //don't check overwrite because openWriteMode open in read-write (doesn't erase content)
|
||||
if (handleID == -1) {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.file_creation_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
gocryptfsVolume.closeFile(handleID)
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.explorer, menu)
|
||||
val result = super.onCreateOptionsMenu(menu)
|
||||
if (currentItemAction != ItemsActions.NONE) {
|
||||
menu.findItem(R.id.validate).isVisible = true
|
||||
menu.findItem(R.id.lock).isVisible = false
|
||||
menu.findItem(R.id.close).isVisible = false
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
} else {
|
||||
handleMenuItems(menu)
|
||||
if (usf_share){
|
||||
menu.findItem(R.id.share).isVisible = false
|
||||
}
|
||||
@ -289,7 +255,7 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
@ -314,11 +280,6 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
R.id.copy -> {
|
||||
for (i in explorerAdapter.selectedItems){
|
||||
itemsToProcess.add(OperationFile.fromExplorerElement(explorerElements[i]))
|
||||
if (explorerElements[i].isDirectory){
|
||||
gocryptfsVolume.recursiveMapFiles(explorerElements[i].fullPath).forEach {
|
||||
itemsToProcess.add(OperationFile.fromExplorerElement(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
currentItemAction = ItemsActions.COPY
|
||||
unselectAll()
|
||||
@ -326,77 +287,113 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
}
|
||||
R.id.validate -> {
|
||||
if (currentItemAction == ItemsActions.COPY){
|
||||
checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items ->
|
||||
items?.let {
|
||||
fileOperationService.copyElements(it.toMutableList() as ArrayList<OperationFile>){ failedItem ->
|
||||
runOnUiThread {
|
||||
if (failedItem == null){
|
||||
Toast.makeText(this, R.string.copy_success, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.copy_failed, failedItem))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
object : LoadingTask<List<OperationFile>>(this, theme, R.string.discovering_files) {
|
||||
override suspend fun doTask(): List<OperationFile> {
|
||||
val items = itemsToProcess.toMutableList()
|
||||
itemsToProcess.filter { it.isDirectory }.forEach { dir ->
|
||||
encryptedVolume.recursiveMapFiles(dir.srcPath)?.forEach {
|
||||
items.add(OperationFile.fromExplorerElement(it))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
cancelItemAction()
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
cancelItemAction()
|
||||
unselectAll()
|
||||
}
|
||||
} else if (currentItemAction == ItemsActions.MOVE){
|
||||
mapFileForMove(itemsToProcess, itemsToProcess[0].explorerElement.parentPath)
|
||||
checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items ->
|
||||
items?.let {
|
||||
fileOperationService.moveElements(it.toMutableList() as ArrayList<OperationFile>){ failedItem ->
|
||||
runOnUiThread {
|
||||
if (failedItem == null){
|
||||
Toast.makeText(this, R.string.move_success, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.move_failed, failedItem))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
itemsToProcess.forEach {
|
||||
it.dstPath = PathUtils.pathJoin(currentDirectoryPath, it.name)
|
||||
it.overwriteConfirmed = false // reset the field in case of a previous cancelled move
|
||||
}
|
||||
val toMove = ArrayList<OperationFile>(itemsToProcess.size)
|
||||
val toClean = ArrayList<String>()
|
||||
prepareFilesForMove(
|
||||
itemsToProcess,
|
||||
toMove,
|
||||
toClean,
|
||||
) {
|
||||
activityScope.launch {
|
||||
onTaskResult(
|
||||
fileOperationService.moveElements(volumeId, toMove, toClean),
|
||||
R.string.move_success,
|
||||
R.string.move_failed,
|
||||
)
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
cancelItemAction()
|
||||
unselectAll()
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.delete -> {
|
||||
val size = explorerAdapter.selectedItems.size
|
||||
val dialog = ColoredAlertDialogBuilder(this)
|
||||
val dialog = CustomAlertDialogBuilder(this, theme)
|
||||
dialog.setTitle(R.string.warning)
|
||||
dialog.setPositiveButton(R.string.ok) { _, _ -> removeSelectedItems() }
|
||||
dialog.setPositiveButton(R.string.ok) { _, _ ->
|
||||
val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
|
||||
activityScope.launch {
|
||||
fileOperationService.removeElements(volumeId, items)?.let { failedItem ->
|
||||
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.remove_failed, failedItem))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
setCurrentPath(currentDirectoryPath) //refresh
|
||||
}
|
||||
unselectAll()
|
||||
}
|
||||
dialog.setNegativeButton(R.string.cancel, null)
|
||||
if (size > 1) {
|
||||
dialog.setMessage(getString(R.string.multiple_delete_confirm, explorerAdapter.selectedItems.size.toString()))
|
||||
} else {
|
||||
dialog.setMessage(getString(R.string.single_delete_confirm, explorerAdapter.getItem(explorerAdapter.selectedItems[0]).name))
|
||||
dialog.setMessage(getString(
|
||||
R.string.single_delete_confirm,
|
||||
explorerAdapter.explorerElements[explorerAdapter.selectedItems.first()].name
|
||||
))
|
||||
}
|
||||
dialog.show()
|
||||
true
|
||||
}
|
||||
R.id.share -> {
|
||||
val paths: MutableList<String> = ArrayList()
|
||||
for (i in explorerAdapter.selectedItems) {
|
||||
paths.add(explorerElements[i].fullPath)
|
||||
val files = explorerAdapter.selectedItems.map { i ->
|
||||
explorerElements[i].let {
|
||||
Pair(it.fullPath, it.stat.size)
|
||||
}
|
||||
}
|
||||
app.isExporting = true
|
||||
object : LoadingTask<Pair<Intent?, Int?>>(this, theme, R.string.loading_msg_export) {
|
||||
override suspend fun doTask(): Pair<Intent?, Int?> {
|
||||
return fileShare.share(files, volumeId)
|
||||
}
|
||||
}.startTask(lifecycleScope) { (intent, error) ->
|
||||
if (intent == null) {
|
||||
onExportFailed(error!!)
|
||||
} else {
|
||||
app.isStartingExternalApp = true
|
||||
startActivity(Intent.createChooser(intent, getString(R.string.share_chooser)))
|
||||
}
|
||||
app.isExporting = false
|
||||
}
|
||||
isStartingActivity = true
|
||||
ExternalProvider.share(this, gocryptfsVolume, paths)
|
||||
unselectAll()
|
||||
true
|
||||
}
|
||||
R.id.decrypt -> {
|
||||
isStartingActivity = true
|
||||
app.isStartingExternalApp = true
|
||||
pickExportDirectory.launch(null)
|
||||
true
|
||||
}
|
||||
@ -404,22 +401,85 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapFileForMove(items: ArrayList<OperationFile>, srcDirectoryPath: String): ArrayList<OperationFile> {
|
||||
val newItems = ArrayList<OperationFile>()
|
||||
items.forEach {
|
||||
if (it.explorerElement.isDirectory){
|
||||
if (gocryptfsVolume.pathExists(PathUtils.pathJoin(currentDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, it.explorerElement.fullPath)))){
|
||||
newItems.addAll(
|
||||
mapFileForMove(
|
||||
gocryptfsVolume.listDir(it.explorerElement.fullPath).map { e -> OperationFile.fromExplorerElement(e) } as ArrayList<OperationFile>,
|
||||
srcDirectoryPath
|
||||
/**
|
||||
* Ask the user what to do if an item would overwrite another item in case of a move.
|
||||
*
|
||||
* All [OperationFile] must have a non-null [dstPath][OperationFile.dstPath].
|
||||
*/
|
||||
private fun checkMoveOverwrite(items: List<OperationFile>, callback: (List<OperationFile>?) -> Unit) {
|
||||
for (item in items) {
|
||||
if (encryptedVolume.pathExists(item.dstPath!!) && !item.overwriteConfirmed) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(
|
||||
getString(
|
||||
if (item.isDirectory) {
|
||||
R.string.dir_overwrite_question
|
||||
} else {
|
||||
R.string.file_overwrite_question
|
||||
},
|
||||
item.dstPath!!
|
||||
)
|
||||
)
|
||||
.setPositiveButton(R.string.yes) {_, _ ->
|
||||
item.overwriteConfirmed = true
|
||||
checkMoveOverwrite(items, callback)
|
||||
}
|
||||
.setNegativeButton(R.string.no) { _, _ ->
|
||||
with(EditTextDialog(this, R.string.enter_new_name) {
|
||||
item.dstPath = PathUtils.pathJoin(PathUtils.getParentPath(item.dstPath!!), it)
|
||||
checkMoveOverwrite(items, callback)
|
||||
}) {
|
||||
setSelectedText(item.name)
|
||||
show()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
return
|
||||
}
|
||||
}
|
||||
callback(items)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for destination overwriting in case of a move operation.
|
||||
*
|
||||
* If the user decides to merge the content of a folder, the function recursively tests all
|
||||
* children of the source folder to see if they will overwrite.
|
||||
*
|
||||
* The items to be moved are stored in [toMove]. We also need to keep track of the merged
|
||||
* folders to delete them after the move. These folders are stored in [toClean].
|
||||
*/
|
||||
private fun prepareFilesForMove(
|
||||
items: List<OperationFile>,
|
||||
toMove: ArrayList<OperationFile>,
|
||||
toClean: ArrayList<String>,
|
||||
onReady: () -> Unit
|
||||
) {
|
||||
checkMoveOverwrite(items) { checkedItems ->
|
||||
checkedItems?.let {
|
||||
for (item in checkedItems) {
|
||||
if (!item.overwriteConfirmed || !item.isDirectory) {
|
||||
toMove.add(item)
|
||||
}
|
||||
}
|
||||
val toCheck = mutableListOf<OperationFile>()
|
||||
for (item in checkedItems) {
|
||||
if (item.overwriteConfirmed && item.isDirectory) {
|
||||
val children = encryptedVolume.readDir(item.srcPath)
|
||||
children?.map {
|
||||
OperationFile(it.fullPath, it.stat.type, PathUtils.pathJoin(item.dstPath!!, it.name))
|
||||
}?.let { toCheck.addAll(it) }
|
||||
toClean.add(item.srcPath)
|
||||
}
|
||||
}
|
||||
if (toCheck.isEmpty()) {
|
||||
onReady()
|
||||
} else {
|
||||
prepareFilesForMove(toCheck, toMove, toClean, onReady)
|
||||
}
|
||||
}
|
||||
}
|
||||
items.addAll(newItems)
|
||||
return items
|
||||
}
|
||||
|
||||
private fun cancelItemAction() {
|
||||
@ -428,39 +488,4 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
itemsToProcess.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (currentItemAction != ItemsActions.NONE) {
|
||||
cancelItemAction()
|
||||
invalidateOptionsMenu()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeSelectedItems() {
|
||||
var failedItem: String? = null
|
||||
for (i in explorerAdapter.selectedItems) {
|
||||
val element = explorerAdapter.getItem(i)
|
||||
val fullPath = PathUtils.pathJoin(currentDirectoryPath, element.name)
|
||||
if (element.isDirectory) {
|
||||
val result = gocryptfsVolume.recursiveRemoveDirectory(fullPath)
|
||||
result?.let{ failedItem = it }
|
||||
} else {
|
||||
if (!gocryptfsVolume.removeFile(fullPath)) {
|
||||
failedItem = fullPath
|
||||
}
|
||||
}
|
||||
if (failedItem != null) {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.remove_failed, failedItem))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
break
|
||||
}
|
||||
}
|
||||
unselectAll()
|
||||
setCurrentPath(currentDirectoryPath) //refresh
|
||||
}
|
||||
}
|
||||
|
@ -2,63 +2,84 @@ package sushi.hardcore.droidfs.explorers
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.databinding.ActivityExplorerDropBinding
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.util.IntentUtils
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.nio.CharBuffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class ExplorerActivityDrop : BaseExplorerActivity() {
|
||||
private lateinit var binding: ActivityExplorerDropBinding
|
||||
|
||||
override fun init() {
|
||||
binding = ActivityExplorerDropBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
binding.fab.setOnClickListener {
|
||||
super.init()
|
||||
findViewById<FloatingActionButton>(R.id.fab).setOnClickListener {
|
||||
openDialogCreateFolder()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.explorer_drop, menu)
|
||||
handleMenuItems(menu)
|
||||
val result = super.onCreateOptionsMenu(menu)
|
||||
menu.findItem(R.id.validate).isVisible = explorerAdapter.selectedItems.isEmpty()
|
||||
return true
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.validate -> {
|
||||
val extras = intent.extras
|
||||
val errorMsg: String? = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
|
||||
val success = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
val uri = IntentUtils.getParcelableExtra<Uri>(intent, Intent.EXTRA_STREAM)
|
||||
if (uri == null) {
|
||||
getString(R.string.share_intent_parsing_failed)
|
||||
false
|
||||
} else {
|
||||
importFilesFromUris(listOf(uri), ::onImported)
|
||||
null
|
||||
true
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
val uris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
val uris: List<Uri>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
@Suppress("Deprecation")
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
if (uris != null) {
|
||||
importFilesFromUris(uris, ::onImported)
|
||||
null
|
||||
true
|
||||
} else {
|
||||
getString(R.string.share_intent_parsing_failed)
|
||||
false
|
||||
}
|
||||
}
|
||||
else -> getString(R.string.share_intent_parsing_failed)
|
||||
else -> false
|
||||
}
|
||||
} else if ((intent.clipData?.itemCount ?: 0) > 0) {
|
||||
val byteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(intent.clipData!!.getItemAt(0).text))
|
||||
val byteArray = ByteArray(byteBuffer.remaining())
|
||||
byteBuffer.get(byteArray)
|
||||
val size = byteArray.size.toLong()
|
||||
createNewFile {
|
||||
var offset = 0L
|
||||
while (offset < size) {
|
||||
offset += encryptedVolume.write(it, offset, byteArray, offset, size-offset)
|
||||
}
|
||||
encryptedVolume.closeFile(it)
|
||||
onImported()
|
||||
}
|
||||
true
|
||||
} else {
|
||||
getString(R.string.share_intent_parsing_failed)
|
||||
false
|
||||
}
|
||||
errorMsg?.let {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
if (!success) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(it)
|
||||
.setMessage(R.string.share_intent_parsing_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
@ -68,23 +89,15 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun onImported(failedItem: String?){
|
||||
private fun onImported() {
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
if (failedItem == null) {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.success_import)
|
||||
.setMessage(R.string.success_import_msg)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok){_, _ ->
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.import_failed, failedItem))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.success_import)
|
||||
.setMessage(R.string.success_import_msg)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
@ -5,39 +5,35 @@ import android.content.Intent
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.IntentUtils
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import java.util.*
|
||||
|
||||
class ExplorerActivityPick : BaseExplorerActivity() {
|
||||
private var resultIntent = Intent()
|
||||
private var isFinishingIntentionally = false
|
||||
override fun init() {
|
||||
super.init()
|
||||
resultIntent.putExtra("sessionID", gocryptfsVolume.sessionID)
|
||||
setContentView(R.layout.activity_explorer_pick)
|
||||
resultIntent.putExtra("volumeId", volumeId)
|
||||
}
|
||||
|
||||
override fun bindFileOperationService() {
|
||||
//don't bind
|
||||
}
|
||||
|
||||
override fun onExplorerItemClick(position: Int) {
|
||||
val wasSelecting = explorerAdapter.selectedItems.isNotEmpty()
|
||||
explorerAdapter.onItemClick(position)
|
||||
override fun onExplorerElementClick(position: Int) {
|
||||
if (explorerAdapter.selectedItems.isEmpty()) {
|
||||
if (!wasSelecting) {
|
||||
val fullPath = PathUtils.pathJoin(currentDirectoryPath, explorerElements[position].name)
|
||||
when {
|
||||
explorerElements[position].isDirectory -> {
|
||||
setCurrentPath(fullPath)
|
||||
}
|
||||
explorerElements[position].isParentFolder -> {
|
||||
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
|
||||
}
|
||||
else -> {
|
||||
resultIntent.putExtra("path", fullPath)
|
||||
returnActivityResult()
|
||||
}
|
||||
val fullPath = PathUtils.pathJoin(currentDirectoryPath, explorerElements[position].name)
|
||||
when {
|
||||
explorerElements[position].isDirectory -> {
|
||||
setCurrentPath(fullPath)
|
||||
}
|
||||
explorerElements[position].isParentFolder -> {
|
||||
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
|
||||
}
|
||||
else -> {
|
||||
resultIntent.putExtra("path", fullPath)
|
||||
returnActivityResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -46,11 +42,11 @@ class ExplorerActivityPick : BaseExplorerActivity() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.explorer_pick, menu)
|
||||
handleMenuItems(menu)
|
||||
val result = super.onCreateOptionsMenu(menu)
|
||||
val anyItemSelected = explorerAdapter.selectedItems.isNotEmpty()
|
||||
menu.findItem(R.id.select_all).isVisible = anyItemSelected
|
||||
menu.findItem(R.id.validate).isVisible = anyItemSelected
|
||||
return true
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
@ -66,7 +62,7 @@ class ExplorerActivityPick : BaseExplorerActivity() {
|
||||
for (i in explorerAdapter.selectedItems) {
|
||||
val e = explorerElements[i]
|
||||
paths.add(PathUtils.pathJoin(currentDirectoryPath, e.name))
|
||||
types.add(e.elementType.toInt())
|
||||
types.add(e.stat.type)
|
||||
}
|
||||
resultIntent.putStringArrayListExtra("paths", paths)
|
||||
resultIntent.putIntegerArrayListExtra("types", types)
|
||||
@ -82,21 +78,4 @@ class ExplorerActivityPick : BaseExplorerActivity() {
|
||||
isFinishingIntentionally = true
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun closeVolumeOnDestroy() {
|
||||
if (!isFinishingIntentionally && !usf_keep_open){
|
||||
val sessionID = intent.getIntExtra("originalSessionID", -1)
|
||||
if (sessionID != -1){
|
||||
val v = GocryptfsVolume(sessionID)
|
||||
v.close()
|
||||
}
|
||||
super.closeVolumeOnDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
override fun closeVolumeOnUserExit() {
|
||||
isFinishingIntentionally = true
|
||||
super.closeVolumeOnUserExit()
|
||||
super.closeVolumeOnDestroy()
|
||||
}
|
||||
}
|
@ -1,26 +1,31 @@
|
||||
package sushi.hardcore.droidfs.explorers
|
||||
|
||||
import sushi.hardcore.droidfs.collation.getCollationKeyForFileName
|
||||
import sushi.hardcore.droidfs.filesystems.Stat
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import java.util.*
|
||||
import java.text.Collator
|
||||
|
||||
class ExplorerElement(val name: String, val elementType: Short, var size: Long = -1, mTime: Long = -1, val parentPath: String) {
|
||||
val mTime = Date((mTime * 1000).toString().toLong())
|
||||
val fullPath: String = PathUtils.pathJoin(parentPath, name)
|
||||
class ExplorerElement(val name: String, val stat: Stat, val parentPath: String) {
|
||||
val fullPath: String = PathUtils.pathJoin(parentPath.ifEmpty { "/" }, name)
|
||||
val collationKey = Collator.getInstance().getCollationKeyForFileName(fullPath)
|
||||
|
||||
val isDirectory: Boolean
|
||||
get() = elementType.toInt() == 0
|
||||
|
||||
val isParentFolder: Boolean
|
||||
get() = elementType.toInt() == -1
|
||||
get() = stat.type == Stat.S_IFDIR
|
||||
|
||||
val isRegularFile: Boolean
|
||||
get() = elementType.toInt() == 1
|
||||
get() = stat.type == Stat.S_IFREG
|
||||
|
||||
val isSymlink: Boolean
|
||||
get() = stat.type == Stat.S_IFLNK
|
||||
|
||||
val isParentFolder: Boolean
|
||||
get() = stat.type == Stat.PARENT_FOLDER_TYPE
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
//this function is needed because I had some problems calling the constructor from JNI, probably due to arguments with default values
|
||||
fun new(name: String, elementType: Short, size: Long, mTime: Long, parentPath: String): ExplorerElement {
|
||||
return ExplorerElement(name, elementType, size, mTime, parentPath)
|
||||
fun new(name: String, elementType: Int, size: Long, mTime: Long, parentPath: String): ExplorerElement {
|
||||
return ExplorerElement(name, Stat(elementType, size, mTime*1000), parentPath)
|
||||
}
|
||||
|
||||
private fun foldersFirst(a: ExplorerElement, b: ExplorerElement, default: () -> Int): Int {
|
||||
@ -33,42 +38,48 @@ class ExplorerElement(val name: String, val elementType: Short, var size: Long =
|
||||
}
|
||||
}
|
||||
private fun doSort(a: ExplorerElement, b: ExplorerElement, foldersFirst: Boolean, sorter: () -> Int): Int {
|
||||
return if (foldersFirst) {
|
||||
foldersFirst(a, b, sorter)
|
||||
return if (b.isParentFolder) {
|
||||
1
|
||||
} else if (a.isParentFolder) {
|
||||
-1
|
||||
} else {
|
||||
sorter()
|
||||
if (foldersFirst) {
|
||||
foldersFirst(a, b, sorter)
|
||||
} else {
|
||||
sorter()
|
||||
}
|
||||
}
|
||||
}
|
||||
fun sortBy(sortOrder: String, foldersFirst: Boolean, explorerElements: MutableList<ExplorerElement>) {
|
||||
when (sortOrder) {
|
||||
"name" -> {
|
||||
explorerElements.sortWith { a, b ->
|
||||
doSort(a, b, foldersFirst) { a.fullPath.compareTo(b.fullPath, true) }
|
||||
doSort(a, b, foldersFirst) { a.collationKey.compareTo(b.collationKey) }
|
||||
}
|
||||
}
|
||||
"size" -> {
|
||||
explorerElements.sortWith { a, b ->
|
||||
doSort(a, b, foldersFirst) { (a.size - b.size).toInt() }
|
||||
doSort(a, b, foldersFirst) { (a.stat.size - b.stat.size).toInt() }
|
||||
}
|
||||
}
|
||||
"date" -> {
|
||||
explorerElements.sortWith { a, b ->
|
||||
doSort(a, b, foldersFirst) { a.mTime.compareTo(b.mTime) }
|
||||
doSort(a, b, foldersFirst) { a.stat.mTime.compareTo(b.stat.mTime) }
|
||||
}
|
||||
}
|
||||
"name_desc" -> {
|
||||
explorerElements.sortWith { a, b ->
|
||||
doSort(a, b, foldersFirst) { b.fullPath.compareTo(a.fullPath, true) }
|
||||
doSort(a, b, foldersFirst) { b.collationKey.compareTo(a.collationKey) }
|
||||
}
|
||||
}
|
||||
"size_desc" -> {
|
||||
explorerElements.sortWith { a, b ->
|
||||
doSort(a, b, foldersFirst) { (b.size - a.size).toInt() }
|
||||
doSort(a, b, foldersFirst) { (b.stat.size - a.stat.size).toInt() }
|
||||
}
|
||||
}
|
||||
"date_desc" -> {
|
||||
explorerElements.sortWith { a, b ->
|
||||
doSort(a, b, foldersFirst) { b.mTime.compareTo(a.mTime) }
|
||||
doSort(a, b, foldersFirst) { b.stat.mTime.compareTo(a.stat.mTime) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,27 @@
|
||||
package sushi.hardcore.droidfs.explorers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import sushi.hardcore.droidfs.util.IntentUtils
|
||||
|
||||
class ExplorerRouter(private val context: Context, private val intent: Intent) {
|
||||
var pickMode = intent.action == "pick"
|
||||
var dropMode = (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) && intent.extras != null
|
||||
|
||||
fun getExplorerIntent(volumeId: Int, volumeShortName: String): Intent {
|
||||
var explorerIntent: Intent? = null
|
||||
if (dropMode) { //import via android share menu
|
||||
explorerIntent = Intent(context, ExplorerActivityDrop::class.java)
|
||||
IntentUtils.forwardIntent(intent, explorerIntent)
|
||||
} else if (pickMode) {
|
||||
explorerIntent = Intent(context, ExplorerActivityPick::class.java)
|
||||
explorerIntent.flags = Intent.FLAG_ACTIVITY_FORWARD_RESULT
|
||||
}
|
||||
if (explorerIntent == null) {
|
||||
explorerIntent = Intent(context, ExplorerActivity::class.java) //default opening
|
||||
}
|
||||
explorerIntent.putExtra("volumeId", volumeId)
|
||||
explorerIntent.putExtra("volumeName", volumeShortName)
|
||||
return explorerIntent
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package sushi.hardcore.droidfs.file_operations
|
||||
|
||||
import android.app.Notification
|
||||
|
||||
class FileOperationNotification(val notificationBuilder: Notification.Builder, val notificationId: Int)
|
@ -1,324 +1,562 @@
|
||||
package sushi.hardcore.droidfs.file_operations
|
||||
|
||||
import android.app.*
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Icon
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.*
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import sushi.hardcore.droidfs.GocryptfsVolume
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import sushi.hardcore.droidfs.BaseActivity
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.NotificationBroadcastReceiver
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.VolumeManager
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.filesystems.Stat
|
||||
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.util.Wiper
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Foreground service for file operations.
|
||||
*
|
||||
* Clients **must** bind to it using the [bind] method.
|
||||
*
|
||||
* This implementation is not thread-safe. It must only be called from the main UI thread.
|
||||
*/
|
||||
class FileOperationService : Service() {
|
||||
companion object {
|
||||
const val NOTIFICATION_CHANNEL_ID = "FileOperations"
|
||||
const val ACTION_CANCEL = "file_operation_cancel"
|
||||
}
|
||||
|
||||
private val binder = LocalBinder()
|
||||
private lateinit var gocryptfsVolume: GocryptfsVolume
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private var notifications = HashMap<Int, Boolean>()
|
||||
private var lastNotificationId = 0
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): FileOperationService = this@FileOperationService
|
||||
fun setGocryptfsVolume(g: GocryptfsVolume) {
|
||||
gocryptfsVolume = g
|
||||
}
|
||||
|
||||
inner class PendingTask<T>(
|
||||
val title: Int,
|
||||
val total: Int?,
|
||||
private val getTask: (Int) -> Deferred<T>,
|
||||
private val onStart: (taskId: Int, job: Deferred<T>) -> Unit,
|
||||
) {
|
||||
fun start(taskId: Int): Deferred<T> = getTask(taskId).also { onStart(taskId, it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "FileOperationService"
|
||||
const val NOTIFICATION_CHANNEL_ID = "FileOperations"
|
||||
const val ACTION_CANCEL = "file_operation_cancel"
|
||||
|
||||
/**
|
||||
* Bind to the service.
|
||||
*
|
||||
* Registers an [ActivityResultLauncher] in the provided activity to request notification permission. Consequently, the activity must not yet be started.
|
||||
*
|
||||
* The activity must stay running while calling the service's methods.
|
||||
*
|
||||
* If multiple activities bind simultaneously, only the latest one will be used by the service.
|
||||
*/
|
||||
fun bind(activity: BaseActivity, onBound: (FileOperationService) -> Unit) {
|
||||
val helper = AndroidUtils.NotificationPermissionHelper(activity)
|
||||
lateinit var service: FileOperationService
|
||||
val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, binder: IBinder) {
|
||||
onBound((binder as FileOperationService.LocalBinder).getService().also {
|
||||
service = it
|
||||
it.notificationPermissionHelpers.addLast(helper)
|
||||
})
|
||||
}
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {}
|
||||
}
|
||||
activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
activity.unbindService(serviceConnection)
|
||||
service.notificationPermissionHelpers.removeLast()
|
||||
}
|
||||
})
|
||||
activity.bindService(
|
||||
Intent(activity, FileOperationService::class.java),
|
||||
serviceConnection,
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder {
|
||||
return binder
|
||||
private var isStarted = false
|
||||
private val binder = LocalBinder()
|
||||
private lateinit var volumeManger: VolumeManager
|
||||
private var serviceScope = MainScope()
|
||||
private val notificationPermissionHelpers = ArrayDeque<AndroidUtils.NotificationPermissionHelper<BaseActivity>>(2)
|
||||
private var askForNotificationPermission = true
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private val notifications = HashMap<Int, NotificationCompat.Builder>()
|
||||
private var foregroundNotificationId = -1
|
||||
private val tasks = HashMap<Int, Job>()
|
||||
private var newTaskId = 1
|
||||
private var pendingTask: PendingTask<*>? = null
|
||||
|
||||
override fun onCreate() {
|
||||
volumeManger = (application as VolumeManagerApp).volumeManager
|
||||
}
|
||||
|
||||
private fun showNotification(message: Int, total: Int?): FileOperationNotification {
|
||||
++lastNotificationId
|
||||
if (!::notificationManager.isInitialized){
|
||||
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
override fun onBind(p0: Intent?): IBinder = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startPendingTask { id, notification ->
|
||||
// on service start, the pending task is the foreground task
|
||||
setForeground(id, notification)
|
||||
}
|
||||
val notificationBuilder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, getString(R.string.file_operations), NotificationManager.IMPORTANCE_LOW)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
isStarted = true
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
private fun processPendingTask() {
|
||||
if (isStarted) {
|
||||
startPendingTask { id, notification ->
|
||||
if (foregroundNotificationId == -1) {
|
||||
// service started but not in foreground yet
|
||||
setForeground(id, notification)
|
||||
} else {
|
||||
// already running in foreground, just add a new notification
|
||||
notificationManager.notify(id, notification)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Notification.Builder(this)
|
||||
}
|
||||
val cancelIntent = Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||
val bundle = Bundle()
|
||||
bundle.putBinder("binder", LocalBinder())
|
||||
bundle.putInt("notificationId", lastNotificationId)
|
||||
putExtra("bundle", bundle)
|
||||
action = ACTION_CANCEL
|
||||
}
|
||||
val cancelPendingIntent = PendingIntent.getBroadcast(this, 0, cancelIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val notificationAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Notification.Action.Builder(
|
||||
Icon.createWithResource(this, R.drawable.icon_close),
|
||||
getString(R.string.cancel),
|
||||
cancelPendingIntent
|
||||
)
|
||||
} else {
|
||||
Notification.Action.Builder(
|
||||
R.drawable.icon_close,
|
||||
getString(R.string.cancel),
|
||||
cancelPendingIntent
|
||||
ContextCompat.startForegroundService(
|
||||
this,
|
||||
Intent(this, FileOperationService::class.java)
|
||||
)
|
||||
}
|
||||
notificationBuilder
|
||||
.setContentTitle(getString(message))
|
||||
.setSmallIcon(R.mipmap.icon_launcher)
|
||||
.setOngoing(true)
|
||||
.addAction(notificationAction.build())
|
||||
if (total != null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the pending task and create an associated notification.
|
||||
*/
|
||||
private fun startPendingTask(showNotification: (id: Int, Notification) -> Unit) {
|
||||
val task = pendingTask
|
||||
pendingTask = null
|
||||
if (task == null) {
|
||||
Log.w(TAG, "Started without pending task")
|
||||
return
|
||||
}
|
||||
if (!::notificationManager.isInitialized) {
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
getString(R.string.file_operations),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
)
|
||||
}
|
||||
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(getString(task.title))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
.addAction(NotificationCompat.Action(
|
||||
R.drawable.icon_close,
|
||||
getString(R.string.cancel),
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
newTaskId,
|
||||
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||
putExtra("bundle", Bundle().apply {
|
||||
putBinder("binder", LocalBinder())
|
||||
putInt("taskId", newTaskId)
|
||||
})
|
||||
action = ACTION_CANCEL
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
))
|
||||
if (task.total != null) {
|
||||
notificationBuilder
|
||||
.setContentText("0/$total")
|
||||
.setProgress(total, 0, false)
|
||||
.setContentText("0/${task.total}")
|
||||
.setProgress(task.total, 0, false)
|
||||
} else {
|
||||
notificationBuilder
|
||||
.setContentText(getString(R.string.discovering_files))
|
||||
.setProgress(0, 0, true)
|
||||
}
|
||||
notifications[lastNotificationId] = false
|
||||
notificationManager.notify(lastNotificationId, notificationBuilder.build())
|
||||
return FileOperationNotification(notificationBuilder, lastNotificationId)
|
||||
showNotification(newTaskId, notificationBuilder.build())
|
||||
notifications[newTaskId] = notificationBuilder
|
||||
tasks[newTaskId] = task.start(newTaskId)
|
||||
newTaskId++
|
||||
}
|
||||
|
||||
private fun updateNotificationProgress(notification: FileOperationNotification, progress: Int, total: Int){
|
||||
notification.notificationBuilder
|
||||
private fun setForeground(id: Int, notification: Notification) {
|
||||
ServiceCompat.startForeground(this, id, notification,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
} else {
|
||||
0
|
||||
}
|
||||
)
|
||||
foregroundNotificationId = id
|
||||
}
|
||||
|
||||
private fun updateNotificationProgress(taskId: Int, progress: Int, total: Int) {
|
||||
val notificationBuilder = notifications[taskId] ?: return
|
||||
notificationBuilder
|
||||
.setProgress(total, progress, false)
|
||||
.setContentText("$progress/$total")
|
||||
notificationManager.notify(notification.notificationId, notification.notificationBuilder.build())
|
||||
notificationManager.notify(taskId, notificationBuilder.build())
|
||||
}
|
||||
|
||||
private fun cancelNotification(notification: FileOperationNotification){
|
||||
notificationManager.cancel(notification.notificationId)
|
||||
fun cancelOperation(taskId: Int) {
|
||||
tasks[taskId]?.cancel()
|
||||
}
|
||||
|
||||
fun cancelOperation(notificationId: Int){
|
||||
notifications[notificationId] = true
|
||||
private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
|
||||
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
|
||||
}
|
||||
|
||||
private fun copyFile(srcPath: String, dstPath: String, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume): Boolean {
|
||||
/**
|
||||
* Wait on a task, returning the appropriate [TaskResult].
|
||||
*
|
||||
* This method also performs cleanup and foreground state management so it must be always used.
|
||||
*/
|
||||
private suspend fun <T> waitForTask(
|
||||
taskId: Int,
|
||||
task: Deferred<T>,
|
||||
onCancelled: (suspend () -> Unit)?,
|
||||
): TaskResult<out T> {
|
||||
return coroutineScope {
|
||||
withContext(serviceScope.coroutineContext) {
|
||||
try {
|
||||
TaskResult.completed(task.await())
|
||||
} catch (e: CancellationException) {
|
||||
onCancelled?.invoke()
|
||||
TaskResult.cancelled()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
TaskResult.error(e.localizedMessage)
|
||||
} finally {
|
||||
notificationManager.cancel(taskId)
|
||||
notifications.remove(taskId)
|
||||
tasks.remove(taskId)
|
||||
if (tasks.size == 0) {
|
||||
// last task finished, remove from foreground state but don't stop the service
|
||||
ServiceCompat.stopForeground(this@FileOperationService, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
foregroundNotificationId = -1
|
||||
} else if (taskId == foregroundNotificationId) {
|
||||
// foreground task finished, falling back to the next one
|
||||
val entry = notifications.entries.first()
|
||||
setForeground(entry.key, entry.value.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and run a new task until completion.
|
||||
*
|
||||
* Handles notification permission request, service startup and notification management.
|
||||
*
|
||||
* Overrides [pendingTask] without checking! (safe if user is not insanely fast)
|
||||
*/
|
||||
private suspend fun <T> newTask(
|
||||
title: Int,
|
||||
total: Int?,
|
||||
getTask: (taskId: Int) -> Deferred<T>,
|
||||
onCancelled: (suspend () -> Unit)?,
|
||||
): TaskResult<out T> {
|
||||
val startedTask = suspendCoroutine { continuation ->
|
||||
val task = PendingTask(title, total, getTask) { taskId, job ->
|
||||
continuation.resume(Pair(taskId, job))
|
||||
}
|
||||
pendingTask = task
|
||||
if (askForNotificationPermission) {
|
||||
with (notificationPermissionHelpers.last()) {
|
||||
askAndRun { granted ->
|
||||
if (granted) {
|
||||
processPendingTask()
|
||||
} else {
|
||||
CustomAlertDialogBuilder(activity, activity.theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.notification_denied_msg)
|
||||
.setPositiveButton(R.string.settings) { _, _ ->
|
||||
(application as VolumeManagerApp).isStartingExternalApp = true
|
||||
activity.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", packageName, null)
|
||||
)
|
||||
)
|
||||
}
|
||||
.setNegativeButton(R.string.later, null)
|
||||
.setOnDismissListener { processPendingTask() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
askForNotificationPermission = false // only ask once per service instance
|
||||
return@suspendCoroutine
|
||||
}
|
||||
processPendingTask()
|
||||
}
|
||||
return waitForTask(startedTask.first, startedTask.second, onCancelled)
|
||||
}
|
||||
|
||||
private suspend fun <T> volumeTask(
|
||||
title: Int,
|
||||
total: Int?,
|
||||
volumeId: Int,
|
||||
task: suspend (taskId: Int, encryptedVolume: EncryptedVolume) -> T
|
||||
): TaskResult<out T> {
|
||||
return newTask(title, total, { taskId ->
|
||||
volumeManger.getCoroutineScope(volumeId).async {
|
||||
task(taskId, getEncryptedVolume(volumeId))
|
||||
}
|
||||
}, null)
|
||||
}
|
||||
|
||||
private suspend fun <T> globalTask(
|
||||
title: Int,
|
||||
total: Int?,
|
||||
task: suspend (taskId: Int) -> T,
|
||||
onCancelled: (suspend () -> Unit)? = null,
|
||||
): TaskResult<out T> {
|
||||
return newTask(title, total, { taskId ->
|
||||
serviceScope.async(Dispatchers.IO) {
|
||||
task(taskId)
|
||||
}
|
||||
}, if (onCancelled == null) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
serviceScope.launch(Dispatchers.IO) {
|
||||
onCancelled()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun copyFile(
|
||||
encryptedVolume: EncryptedVolume,
|
||||
srcPath: String,
|
||||
dstPath: String,
|
||||
srcEncryptedVolume: EncryptedVolume = encryptedVolume,
|
||||
): Boolean {
|
||||
var success = true
|
||||
val srcHandleId = remoteGocryptfsVolume.openReadMode(srcPath)
|
||||
if (srcHandleId != -1){
|
||||
val dstHandleId = gocryptfsVolume.openWriteMode(dstPath)
|
||||
if (dstHandleId != -1){
|
||||
val srcFileHandle = srcEncryptedVolume.openFileReadMode(srcPath)
|
||||
if (srcFileHandle != -1L) {
|
||||
val dstFileHandle = encryptedVolume.openFileWriteMode(dstPath)
|
||||
if (dstFileHandle != -1L) {
|
||||
var offset: Long = 0
|
||||
val ioBuffer = ByteArray(GocryptfsVolume.DefaultBS)
|
||||
var length: Int
|
||||
while (remoteGocryptfsVolume.readFile(srcHandleId, offset, ioBuffer).also { length = it } > 0) {
|
||||
val written = gocryptfsVolume.writeFile(dstHandleId, offset, ioBuffer, length).toLong()
|
||||
if (written == length.toLong()) {
|
||||
val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
|
||||
var length: Long
|
||||
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()
|
||||
if (written == length) {
|
||||
offset += written
|
||||
} else {
|
||||
success = false
|
||||
break
|
||||
}
|
||||
}
|
||||
gocryptfsVolume.closeFile(dstHandleId)
|
||||
encryptedVolume.truncate(dstPath, offset)
|
||||
encryptedVolume.closeFile(dstFileHandle)
|
||||
} else {
|
||||
success = false
|
||||
}
|
||||
remoteGocryptfsVolume.closeFile(srcHandleId)
|
||||
srcEncryptedVolume.closeFile(srcFileHandle)
|
||||
} else {
|
||||
success = false
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
fun copyElements(items: ArrayList<OperationFile>, remoteGocryptfsVolume: GocryptfsVolume = gocryptfsVolume, callback: (String?) -> Unit){
|
||||
Thread {
|
||||
val notification = showNotification(R.string.file_op_copy_msg, items.size)
|
||||
suspend fun copyElements(
|
||||
volumeId: Int,
|
||||
items: List<OperationFile>,
|
||||
srcVolumeId: Int = volumeId,
|
||||
): TaskResult<out String?> {
|
||||
val srcEncryptedVolume = getEncryptedVolume(srcVolumeId)
|
||||
return volumeTask(R.string.file_op_copy_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||
var failedItem: String? = null
|
||||
for (i in 0 until items.size){
|
||||
if (notifications[notification.notificationId]!!){
|
||||
cancelNotification(notification)
|
||||
return@Thread
|
||||
}
|
||||
if (items[i].explorerElement.isDirectory){
|
||||
if (!gocryptfsVolume.pathExists(items[i].dstPath!!)) {
|
||||
if (!gocryptfsVolume.mkdir(items[i].dstPath!!)) {
|
||||
failedItem = items[i].explorerElement.fullPath
|
||||
for (i in items.indices) {
|
||||
yield()
|
||||
if (items[i].isDirectory) {
|
||||
if (!encryptedVolume.pathExists(items[i].dstPath!!)) {
|
||||
if (!encryptedVolume.mkdir(items[i].dstPath!!)) {
|
||||
failedItem = items[i].srcPath
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!copyFile(items[i].explorerElement.fullPath, items[i].dstPath!!, remoteGocryptfsVolume)){
|
||||
failedItem = items[i].explorerElement.fullPath
|
||||
}
|
||||
} else if (!copyFile(encryptedVolume, items[i].srcPath, items[i].dstPath!!, srcEncryptedVolume)) {
|
||||
failedItem = items[i].srcPath
|
||||
}
|
||||
if (failedItem == null){
|
||||
updateNotificationProgress(notification, i, items.size)
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(taskId, i+1, items.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
cancelNotification(notification)
|
||||
callback(failedItem)
|
||||
}.start()
|
||||
failedItem
|
||||
}
|
||||
}
|
||||
|
||||
fun moveElements(items: ArrayList<OperationFile>, callback: (String?) -> Unit){
|
||||
Thread {
|
||||
val notification = showNotification(R.string.file_op_move_msg, items.size)
|
||||
val mergedFolders = ArrayList<String>()
|
||||
suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
|
||||
return volumeTask(R.string.file_op_move_msg, toMove.size, volumeId) { taskId, encryptedVolume ->
|
||||
val total = toMove.size+toClean.size
|
||||
var failedItem: String? = null
|
||||
for (i in 0 until items.size){
|
||||
if (notifications[notification.notificationId]!!){
|
||||
cancelNotification(notification)
|
||||
return@Thread
|
||||
}
|
||||
if (items[i].explorerElement.isDirectory && gocryptfsVolume.pathExists(items[i].dstPath!!)){ //folder will be merged
|
||||
mergedFolders.add(items[i].explorerElement.fullPath)
|
||||
for ((i, item) in toMove.withIndex()) {
|
||||
if (!encryptedVolume.rename(item.srcPath, item.dstPath!!)) {
|
||||
failedItem = item.srcPath
|
||||
break
|
||||
} else {
|
||||
if (!gocryptfsVolume.rename(items[i].explorerElement.fullPath, items[i].dstPath!!)){
|
||||
failedItem = items[i].explorerElement.fullPath
|
||||
updateNotificationProgress(taskId, i+1, total)
|
||||
}
|
||||
}
|
||||
if (failedItem == null) {
|
||||
for ((i, folderPath) in toClean.asReversed().withIndex()) {
|
||||
if (!encryptedVolume.rmdir(folderPath)) {
|
||||
failedItem = folderPath
|
||||
break
|
||||
} else {
|
||||
updateNotificationProgress(notification, i, items.size)
|
||||
updateNotificationProgress(taskId, toMove.size+i+1, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failedItem == null){
|
||||
for (i in 0 until mergedFolders.size) {
|
||||
if (notifications[notification.notificationId]!!){
|
||||
cancelNotification(notification)
|
||||
return@Thread
|
||||
}
|
||||
if (!gocryptfsVolume.rmdir(mergedFolders[i])){
|
||||
failedItem = mergedFolders[i]
|
||||
break
|
||||
} else {
|
||||
updateNotificationProgress(notification, items.size-(mergedFolders.size-i), items.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
cancelNotification(notification)
|
||||
callback(failedItem)
|
||||
}.start()
|
||||
failedItem
|
||||
}
|
||||
}
|
||||
|
||||
private fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>, reuseNotification: FileOperationNotification? = null, callback: (String?) -> Unit){
|
||||
val notification = reuseNotification ?: showNotification(R.string.file_op_import_msg, dstPaths.size)
|
||||
private suspend fun importFilesFromUris(
|
||||
encryptedVolume: EncryptedVolume,
|
||||
dstPaths: List<String>,
|
||||
uris: List<Uri>,
|
||||
taskId: Int,
|
||||
): String? {
|
||||
var failedIndex = -1
|
||||
for (i in dstPaths.indices) {
|
||||
if (notifications[notification.notificationId]!!){
|
||||
cancelNotification(notification)
|
||||
return
|
||||
}
|
||||
yield()
|
||||
try {
|
||||
if (!gocryptfsVolume.importFile(this, uris[i], dstPaths[i])) {
|
||||
if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
|
||||
failedIndex = i
|
||||
}
|
||||
} catch (e: FileNotFoundException){
|
||||
} catch (e: FileNotFoundException) {
|
||||
failedIndex = i
|
||||
}
|
||||
if (failedIndex == -1) {
|
||||
updateNotificationProgress(notification, i, dstPaths.size)
|
||||
updateNotificationProgress(taskId, i+1, dstPaths.size)
|
||||
} else {
|
||||
cancelNotification(notification)
|
||||
callback(uris[failedIndex].toString())
|
||||
break
|
||||
return uris[failedIndex].toString()
|
||||
}
|
||||
}
|
||||
if (failedIndex == -1){
|
||||
cancelNotification(notification)
|
||||
callback(null)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>, callback: (String?) -> Unit) {
|
||||
Thread {
|
||||
importFilesFromUris(dstPaths, uris, null, callback)
|
||||
}.start()
|
||||
suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
|
||||
return volumeTask(R.string.file_op_import_msg, dstPaths.size, volumeId) { taskId, encryptedVolume ->
|
||||
importFilesFromUris(encryptedVolume, dstPaths, uris, taskId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the content of an unencrypted directory to prepare its import
|
||||
*
|
||||
* Contents of dstFiles and srcUris, at the same index, will match each other
|
||||
*
|
||||
* @return false if cancelled early, true otherwise.
|
||||
*/
|
||||
private fun recursiveMapDirectoryForImport(
|
||||
private suspend fun recursiveMapDirectoryForImport(
|
||||
rootSrcDir: DocumentFile,
|
||||
rootDstPath: String,
|
||||
dstFiles: ArrayList<String>,
|
||||
srcUris: ArrayList<Uri>,
|
||||
dstDirs: ArrayList<String>,
|
||||
notification: FileOperationNotification
|
||||
): Boolean {
|
||||
) {
|
||||
dstDirs.add(rootDstPath)
|
||||
for (child in rootSrcDir.listFiles()) {
|
||||
if (notifications[notification.notificationId]!!) {
|
||||
cancelNotification(notification)
|
||||
return false
|
||||
}
|
||||
yield()
|
||||
child.name?.let { name ->
|
||||
val subPath = PathUtils.pathJoin(rootDstPath, name)
|
||||
if (child.isDirectory) {
|
||||
if (!recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs, notification)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
else if (child.isFile) {
|
||||
recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs)
|
||||
} else if (child.isFile) {
|
||||
srcUris.add(child.uri)
|
||||
dstFiles.add(subPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun importDirectory(rootDstPath: String, rootSrcDir: DocumentFile, callback: (String?, List<Uri>) -> Unit) {
|
||||
Thread {
|
||||
val notification = showNotification(R.string.file_op_import_msg, null)
|
||||
class ImportDirectoryResult(val taskResult: TaskResult<out String?>, val uris: List<Uri>)
|
||||
|
||||
suspend fun importDirectory(
|
||||
volumeId: Int,
|
||||
rootDstPath: String,
|
||||
rootSrcDir: DocumentFile,
|
||||
): ImportDirectoryResult {
|
||||
val srcUris = arrayListOf<Uri>()
|
||||
return ImportDirectoryResult(volumeTask(R.string.file_op_import_msg, null, volumeId) { taskId, encryptedVolume ->
|
||||
var failedItem: String? = null
|
||||
val dstFiles = arrayListOf<String>()
|
||||
val srcUris = arrayListOf<Uri>()
|
||||
val dstDirs = arrayListOf<String>()
|
||||
if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, notification)) {
|
||||
return@Thread
|
||||
}
|
||||
|
||||
recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs)
|
||||
// create destination folders so the new files can use them
|
||||
for (dir in dstDirs) {
|
||||
if (notifications[notification.notificationId]!!) {
|
||||
cancelNotification(notification)
|
||||
return@Thread
|
||||
}
|
||||
if (!gocryptfsVolume.mkdir(dir)) {
|
||||
cancelNotification(notification)
|
||||
callback(dir, srcUris)
|
||||
// if directory creation fails, check if it was already present
|
||||
if (!encryptedVolume.mkdir(dir) && encryptedVolume.getAttr(dir)?.type != Stat.S_IFDIR) {
|
||||
failedItem = dir
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
importFilesFromUris(dstFiles, srcUris, notification) { failedItem ->
|
||||
callback(failedItem, srcUris)
|
||||
if (failedItem == null) {
|
||||
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, taskId)
|
||||
}
|
||||
}.start()
|
||||
failedItem
|
||||
}, srcUris)
|
||||
}
|
||||
|
||||
fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null, callback: (String?) -> Unit){
|
||||
Thread {
|
||||
val notification = showNotification(R.string.file_op_wiping_msg, uris.size)
|
||||
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
|
||||
return globalTask(R.string.file_op_wiping_msg, uris.size, { taskId ->
|
||||
var errorMsg: String? = null
|
||||
for (i in uris.indices) {
|
||||
if (notifications[notification.notificationId]!!){
|
||||
cancelNotification(notification)
|
||||
return@Thread
|
||||
}
|
||||
errorMsg = Wiper.wipe(this, uris[i])
|
||||
yield()
|
||||
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
|
||||
if (errorMsg == null) {
|
||||
updateNotificationProgress(notification, i, uris.size)
|
||||
updateNotificationProgress(taskId, i+1, uris.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@ -326,34 +564,35 @@ class FileOperationService : Service() {
|
||||
if (errorMsg == null) {
|
||||
rootFile?.delete()
|
||||
}
|
||||
cancelNotification(notification)
|
||||
callback(errorMsg)
|
||||
}.start()
|
||||
errorMsg
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
contentResolver.openOutputStream(it)
|
||||
}
|
||||
return if (outputStream == null) {
|
||||
false
|
||||
} else {
|
||||
gocryptfsVolume.exportFile(srcPath, outputStream)
|
||||
encryptedVolume.exportFile(srcPath, outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recursiveExportDirectory(plain_directory_path: String, treeDocumentFile: DocumentFile): String? {
|
||||
private suspend fun recursiveExportDirectory(
|
||||
encryptedVolume: EncryptedVolume,
|
||||
plain_directory_path: String,
|
||||
treeDocumentFile: DocumentFile,
|
||||
): String? {
|
||||
treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree ->
|
||||
val explorerElements = gocryptfsVolume.listDir(plain_directory_path)
|
||||
val explorerElements = encryptedVolume.readDir(plain_directory_path) ?: return null
|
||||
for (e in explorerElements) {
|
||||
yield()
|
||||
val fullPath = PathUtils.pathJoin(plain_directory_path, e.name)
|
||||
if (e.isDirectory) {
|
||||
val failedItem = recursiveExportDirectory(fullPath, childTree)
|
||||
failedItem?.let { return it }
|
||||
} else {
|
||||
if (!exportFileInto(fullPath, childTree)){
|
||||
return fullPath
|
||||
}
|
||||
recursiveExportDirectory(encryptedVolume, fullPath, childTree)?.let { return it }
|
||||
} else if (!exportFileInto(encryptedVolume, fullPath, childTree)) {
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
@ -361,31 +600,123 @@ class FileOperationService : Service() {
|
||||
return treeDocumentFile.name
|
||||
}
|
||||
|
||||
fun exportFiles(uri: Uri, items: List<ExplorerElement>, callback: (String?) -> Unit){
|
||||
Thread {
|
||||
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
DocumentFile.fromTreeUri(this, uri)?.let { treeDocumentFile ->
|
||||
val notification = showNotification(R.string.file_op_export_msg, items.size)
|
||||
var failedItem: String? = null
|
||||
for (i in items.indices) {
|
||||
if (notifications[notification.notificationId]!!){
|
||||
cancelNotification(notification)
|
||||
return@Thread
|
||||
}
|
||||
failedItem = if (items[i].isDirectory) {
|
||||
recursiveExportDirectory(items[i].fullPath, treeDocumentFile)
|
||||
suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
|
||||
return volumeTask(R.string.file_op_export_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
|
||||
var failedItem: String? = null
|
||||
for (i in items.indices) {
|
||||
yield()
|
||||
failedItem = if (items[i].isDirectory) {
|
||||
recursiveExportDirectory(encryptedVolume, items[i].fullPath, treeDocumentFile)
|
||||
} else {
|
||||
if (exportFileInto(encryptedVolume, items[i].fullPath, treeDocumentFile)) {
|
||||
null
|
||||
} else {
|
||||
if (exportFileInto(items[i].fullPath, treeDocumentFile)) null else items[i].fullPath
|
||||
}
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(notification, i, items.size)
|
||||
} else {
|
||||
break
|
||||
items[i].fullPath
|
||||
}
|
||||
}
|
||||
cancelNotification(notification)
|
||||
callback(failedItem)
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(taskId, i+1, items.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}.start()
|
||||
failedItem
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recursiveRemoveDirectory(encryptedVolume: EncryptedVolume, path: String): String? {
|
||||
encryptedVolume.readDir(path)?.let { elements ->
|
||||
for (e in elements) {
|
||||
yield()
|
||||
val fullPath = PathUtils.pathJoin(path, e.name)
|
||||
if (e.isDirectory) {
|
||||
recursiveRemoveDirectory(encryptedVolume, fullPath)?.let { return it }
|
||||
} else if (!encryptedVolume.deleteFile(fullPath)) {
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (!encryptedVolume.rmdir(path)) {
|
||||
path
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
|
||||
return volumeTask(R.string.file_op_delete_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||
var failedItem: String? = null
|
||||
for ((i, element) in items.withIndex()) {
|
||||
yield()
|
||||
if (element.isDirectory) {
|
||||
recursiveRemoveDirectory(encryptedVolume, element.fullPath)?.let { failedItem = it }
|
||||
} else if (!encryptedVolume.deleteFile(element.fullPath)) {
|
||||
failedItem = element.fullPath
|
||||
}
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(taskId, i + 1, items.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
failedItem
|
||||
}.failedItem // treat cancellation as success
|
||||
}
|
||||
|
||||
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile): Int {
|
||||
yield()
|
||||
val children = rootDirectory.listFiles()
|
||||
var count = children.size
|
||||
for (child in children) {
|
||||
if (child.isDirectory) {
|
||||
count += recursiveCountChildElements(child)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private suspend fun recursiveCopyVolume(
|
||||
src: DocumentFile,
|
||||
dst: DocumentFile,
|
||||
dstRootDirectory: ObjRef<DocumentFile?>?,
|
||||
taskId: Int,
|
||||
total: Int,
|
||||
progress: ObjRef<Int> = ObjRef(0)
|
||||
): DocumentFile? {
|
||||
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
|
||||
dstRootDirectory?.let { it.value = dstDir }
|
||||
for (child in src.listFiles()) {
|
||||
yield()
|
||||
if (child.isFile) {
|
||||
val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child
|
||||
val outputStream = contentResolver.openOutputStream(dstFile.uri)
|
||||
val inputStream = contentResolver.openInputStream(child.uri)
|
||||
if (outputStream == null || inputStream == null) return child
|
||||
val written = inputStream.copyTo(outputStream)
|
||||
outputStream.close()
|
||||
inputStream.close()
|
||||
if (written != child.length()) return child
|
||||
} else {
|
||||
recursiveCopyVolume(child, dstDir, null, taskId, total, progress)?.let { return it }
|
||||
}
|
||||
progress.value++
|
||||
updateNotificationProgress(taskId, progress.value, total)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
|
||||
|
||||
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
|
||||
val dstRootDirectory = ObjRef<DocumentFile?>(null)
|
||||
val result = globalTask(R.string.copy_volume_notification, null, { taskId ->
|
||||
val total = recursiveCountChildElements(src)
|
||||
updateNotificationProgress(taskId, 0, total)
|
||||
recursiveCopyVolume(src, dst, dstRootDirectory, taskId, total)
|
||||
}, {
|
||||
dstRootDirectory.value?.delete()
|
||||
})
|
||||
return CopyVolumeResult(result, dstRootDirectory.value)
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package sushi.hardcore.droidfs.file_operations
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class NotificationBroadcastReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == FileOperationService.ACTION_CANCEL){
|
||||
intent.getBundleExtra("bundle")?.let { bundle ->
|
||||
(bundle.getBinder("binder") as FileOperationService.LocalBinder?)?.let { binder ->
|
||||
val notificationId = bundle.getInt("notificationId")
|
||||
val service = binder.getService()
|
||||
service.cancelOperation(notificationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +1,22 @@
|
||||
package sushi.hardcore.droidfs.file_operations
|
||||
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.filesystems.Stat
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import java.io.File
|
||||
|
||||
class OperationFile(val srcPath: String, val type: Int, var dstPath: String? = null, var overwriteConfirmed: Boolean = false) {
|
||||
val isDirectory = type == Stat.S_IFDIR
|
||||
val name: String by lazy {
|
||||
File(srcPath).name
|
||||
}
|
||||
val parentPath by lazy {
|
||||
PathUtils.getParentPath(srcPath)
|
||||
}
|
||||
|
||||
class OperationFile(val explorerElement: ExplorerElement, var dstPath: String? = null, var overwriteConfirmed: Boolean = false) {
|
||||
companion object {
|
||||
fun fromExplorerElement(e: ExplorerElement): OperationFile {
|
||||
return OperationFile(e, null)
|
||||
return OperationFile(e.fullPath, e.stat.type)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import sushi.hardcore.droidfs.databinding.ActivityAudioPlayerBinding
|
||||
import java.io.File
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class AudioPlayer: MediaPlayer(){
|
||||
private lateinit var binding: ActivityAudioPlayerBinding
|
||||
|
||||
@ -11,28 +13,17 @@ class AudioPlayer: MediaPlayer(){
|
||||
binding = ActivityAudioPlayerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
super.viewFile()
|
||||
refreshFileName()
|
||||
}
|
||||
|
||||
override fun getFileType(): String {
|
||||
return "audio"
|
||||
}
|
||||
|
||||
override fun bindPlayer(player: SimpleExoPlayer) {
|
||||
override fun bindPlayer(player: ExoPlayer) {
|
||||
binding.audioController.player = player
|
||||
}
|
||||
|
||||
override fun onPlaylistIndexChanged() {
|
||||
refreshFileName()
|
||||
}
|
||||
|
||||
private fun refreshFileName() {
|
||||
val filename = File(filePath).name
|
||||
val pos = filename.lastIndexOf('.')
|
||||
binding.musicTitle.text = if (pos != -1){
|
||||
filename.substring(0,pos)
|
||||
} else {
|
||||
filename
|
||||
}
|
||||
override fun onNewFileName(fileName: String) {
|
||||
binding.musicTitle.text = fileName
|
||||
}
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.C
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import kotlin.math.min
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, private val filePath: String):
|
||||
DataSource {
|
||||
private var fileHandle = -1L
|
||||
private var fileOffset: Long = 0
|
||||
private var bytesRemaining: Long = -1
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
fileHandle = encryptedVolume.openFileReadMode(filePath)
|
||||
fileOffset = dataSpec.position
|
||||
val fileSize = encryptedVolume.getAttr(filePath)!!.size
|
||||
bytesRemaining = if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
|
||||
fileSize - fileOffset
|
||||
} else {
|
||||
min(fileSize, dataSpec.length)
|
||||
}
|
||||
return bytesRemaining
|
||||
}
|
||||
|
||||
override fun getUri(): Uri {
|
||||
return Constants.FAKE_URI
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
encryptedVolume.closeFile(fileHandle)
|
||||
}
|
||||
|
||||
override fun addTransferListener(transferListener: TransferListener) {
|
||||
//too lazy to implement this
|
||||
}
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int {
|
||||
val originalOffset = fileOffset
|
||||
while (fileOffset < originalOffset+readLength && encryptedVolume.read(
|
||||
fileHandle,
|
||||
fileOffset,
|
||||
buffer,
|
||||
offset+(fileOffset-originalOffset),
|
||||
(originalOffset+readLength)-fileOffset
|
||||
).also { fileOffset += it } > 0
|
||||
) {}
|
||||
val totalRead = fileOffset-originalOffset
|
||||
bytesRemaining -= totalRead
|
||||
return if (totalRead == 0L) {
|
||||
C.RESULT_END_OF_INPUT
|
||||
} else {
|
||||
totalRead.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val encryptedVolume: EncryptedVolume, private val filePath: String): DataSource.Factory {
|
||||
override fun createDataSource(): DataSource {
|
||||
return EncryptedVolumeDataSource(encryptedVolume, filePath)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,114 +1,144 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updateMargins
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sushi.hardcore.droidfs.BaseActivity
|
||||
import sushi.hardcore.droidfs.ConstValues
|
||||
import sushi.hardcore.droidfs.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.FileTypes
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.IntentUtils
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.util.finishOnClose
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
|
||||
abstract class FileViewerActivity: BaseActivity() {
|
||||
protected lateinit var gocryptfsVolume: GocryptfsVolume
|
||||
protected lateinit var encryptedVolume: EncryptedVolume
|
||||
protected lateinit var filePath: String
|
||||
private lateinit var originalParentPath: String
|
||||
private lateinit var windowInsetsController: WindowInsetsControllerCompat
|
||||
private var windowTypeMask = 0
|
||||
private var isFinishingIntentionally = false
|
||||
private var usf_keep_open = false
|
||||
private var foldersFirst = true
|
||||
private var wasMapped = false
|
||||
protected val mappedPlaylist = mutableListOf<ExplorerElement>()
|
||||
protected var currentPlaylistIndex = -1
|
||||
private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
filePath = intent.getStringExtra("path")!!
|
||||
originalParentPath = PathUtils.getParentPath(filePath)
|
||||
val sessionID = intent.getIntExtra("sessionID", -1)
|
||||
gocryptfsVolume = GocryptfsVolume(sessionID)
|
||||
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false)
|
||||
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
|
||||
intent.getIntExtra("volumeId", -1)
|
||||
)!!
|
||||
finishOnClose(encryptedVolume)
|
||||
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
|
||||
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
|
||||
windowTypeMask = typeMask
|
||||
}
|
||||
hideSystemUi()
|
||||
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
|
||||
viewFile()
|
||||
}
|
||||
|
||||
open fun hideSystemUi(){
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
|
||||
open fun showPartialSystemUi() {
|
||||
if (isLegacyFullscreen) {
|
||||
@Suppress("Deprecation")
|
||||
window.decorView.systemUiVisibility =
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
} else {
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
|
||||
windowInsetsController.show(WindowInsetsCompat.Type.navigationBars())
|
||||
}
|
||||
}
|
||||
|
||||
open fun hideSystemUi() {
|
||||
if (isLegacyFullscreen) {
|
||||
@Suppress("Deprecation")
|
||||
window.decorView.systemUiVisibility =
|
||||
View.SYSTEM_UI_FLAG_LOW_PROFILE or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
} else {
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.setDecorFitsSystemWindows(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected fun applyNavigationBarMargin(root: View) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
|
||||
root.updateLayoutParams<FrameLayout.LayoutParams> {
|
||||
val newInsets = insets.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars())
|
||||
this.updateMargins(
|
||||
left = newInsets.left,
|
||||
top = newInsets.top,
|
||||
right = newInsets.right,
|
||||
bottom = newInsets.bottom
|
||||
)
|
||||
}
|
||||
insets
|
||||
}
|
||||
} else {
|
||||
root.fitsSystemWindows = true
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun getFileType(): String
|
||||
abstract fun viewFile()
|
||||
|
||||
override fun onUserInteraction() {
|
||||
super.onUserInteraction()
|
||||
if (windowTypeMask and WindowInsetsCompat.Type.statusBars() == 0) {
|
||||
hideSystemUi()
|
||||
}
|
||||
}
|
||||
|
||||
protected fun loadWholeFile(path: String): ByteArray? {
|
||||
val fileSize = gocryptfsVolume.getSize(path)
|
||||
if (fileSize >= 0){
|
||||
try {
|
||||
val fileBuff = ByteArray(fileSize.toInt())
|
||||
var success = false
|
||||
val handleID = gocryptfsVolume.openReadMode(path)
|
||||
if (handleID != -1) {
|
||||
var offset: Long = 0
|
||||
val ioBuffer = ByteArray(GocryptfsVolume.DefaultBS)
|
||||
var length: Int
|
||||
while (gocryptfsVolume.readFile(handleID, offset, ioBuffer).also { length = it } > 0){
|
||||
System.arraycopy(ioBuffer, 0, fileBuff, offset.toInt(), length)
|
||||
offset += length.toLong()
|
||||
protected fun loadWholeFile(path: String, fileSize: Long? = null, callback: (ByteArray) -> Unit) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = encryptedVolume.loadWholeFile(path, size = fileSize)
|
||||
if (isActive) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result.second == 0) {
|
||||
callback(result.first!!)
|
||||
} else {
|
||||
val dialog = CustomAlertDialogBuilder(this@FileViewerActivity, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() }
|
||||
when (result.second) {
|
||||
1 -> dialog.setMessage(R.string.get_size_failed)
|
||||
2 -> dialog.setMessage(R.string.outofmemoryerror_msg)
|
||||
3 -> dialog.setMessage(R.string.read_file_failed)
|
||||
4 -> dialog.setMessage(R.string.io_error)
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
gocryptfsVolume.closeFile(handleID)
|
||||
success = offset == fileBuff.size.toLong()
|
||||
}
|
||||
if (success){
|
||||
return fileBuff
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.read_file_failed)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() }
|
||||
.show()
|
||||
}
|
||||
} catch (e: OutOfMemoryError){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.outofmemoryerror_msg)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() }
|
||||
.show()
|
||||
}
|
||||
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.get_size_failed)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> goBackToExplorer() }
|
||||
.show()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
protected fun createPlaylist() {
|
||||
if (!wasMapped){
|
||||
for (e in gocryptfsVolume.recursiveMapFiles(originalParentPath)) {
|
||||
if (e.isRegularFile) {
|
||||
if (ConstValues.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) {
|
||||
mappedPlaylist.add(e)
|
||||
encryptedVolume.recursiveMapFiles(originalParentPath)?.let { elements ->
|
||||
for (e in elements) {
|
||||
if (e.isRegularFile) {
|
||||
if (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) {
|
||||
mappedPlaylist.add(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -146,27 +176,6 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
}
|
||||
|
||||
protected fun goBackToExplorer() {
|
||||
isFinishingIntentionally = true
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (!isFinishingIntentionally) {
|
||||
gocryptfsVolume.close()
|
||||
RestrictedFileProvider.wipeAll(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (!usf_keep_open) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
super.onBackPressed()
|
||||
isFinishingIntentionally = true
|
||||
}
|
||||
}
|
||||
|
@ -1,60 +0,0 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import android.net.Uri
|
||||
import com.google.android.exoplayer2.upstream.DataSource
|
||||
import com.google.android.exoplayer2.upstream.DataSpec
|
||||
import com.google.android.exoplayer2.upstream.TransferListener
|
||||
import sushi.hardcore.droidfs.ConstValues
|
||||
import sushi.hardcore.droidfs.GocryptfsVolume
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.min
|
||||
|
||||
class GocryptfsDataSource(private val gocryptfsVolume: GocryptfsVolume, private val filePath: String): DataSource {
|
||||
private var handleID = -1
|
||||
private var fileSize: Long = -1
|
||||
private var fileOffset: Long = 0
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
fileOffset = dataSpec.position
|
||||
handleID = gocryptfsVolume.openReadMode(filePath)
|
||||
fileSize = gocryptfsVolume.getSize(filePath)
|
||||
return fileSize
|
||||
}
|
||||
|
||||
override fun getUri(): Uri {
|
||||
return ConstValues.fakeUri
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
gocryptfsVolume.closeFile(handleID)
|
||||
}
|
||||
|
||||
override fun addTransferListener(transferListener: TransferListener) {
|
||||
//too lazy to implement this
|
||||
}
|
||||
|
||||
override fun read(buffer: ByteArray, offset: Int, readLength: Int): Int {
|
||||
if (fileOffset >= fileSize){
|
||||
return -1
|
||||
}
|
||||
var totalRead = 0
|
||||
for (i in 0 until ceil(readLength.toDouble()/ConstValues.MAX_KERNEL_WRITE).toInt()){
|
||||
val tmpReadLength = min(readLength-totalRead, ConstValues.MAX_KERNEL_WRITE)
|
||||
val tmpBuff = if (fileOffset+tmpReadLength > fileSize){
|
||||
ByteArray((fileSize-fileOffset).toInt())
|
||||
} else {
|
||||
ByteArray(tmpReadLength)
|
||||
}
|
||||
val read = gocryptfsVolume.readFile(handleID, fileOffset, tmpBuff)
|
||||
System.arraycopy(tmpBuff, 0, buffer, offset+totalRead, read)
|
||||
fileOffset += read
|
||||
totalRead += read
|
||||
}
|
||||
return totalRead
|
||||
}
|
||||
|
||||
class Factory(private val gocryptfsVolume: GocryptfsVolume, private val filePath: String): DataSource.Factory {
|
||||
override fun createDataSource(): DataSource {
|
||||
return GocryptfsDataSource(gocryptfsVolume, filePath)
|
||||
}
|
||||
}
|
||||
}
|
@ -9,14 +9,17 @@ import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.RequestBuilder
|
||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||
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.databinding.ActivityImageViewerBinding
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.ZoomableImageView
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
@ -30,17 +33,23 @@ class ImageViewer: FileViewerActivity() {
|
||||
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 handler: Handler
|
||||
private lateinit var glideImage: RequestBuilder<Drawable>
|
||||
private val imageViewModel: ImageViewModel by viewModels()
|
||||
private var requestBuilder: RequestBuilder<Drawable>? = null
|
||||
private var x1 = 0F
|
||||
private var x2 = 0F
|
||||
private var slideshowActive = false
|
||||
private var rotationAngle: Float = 0F
|
||||
private var rotatedBitmap: Bitmap? = null
|
||||
private var orientationTransformation: OrientationTransformation? = null
|
||||
private val hideUI = Runnable {
|
||||
binding.actionButtons.visibility = View.GONE
|
||||
binding.actionBar.visibility = View.GONE
|
||||
binding.topBar.visibility = View.GONE
|
||||
hideSystemUi()
|
||||
}
|
||||
private val slideshowNext = Runnable {
|
||||
if (slideshowActive){
|
||||
@ -57,13 +66,17 @@ class ImageViewer: FileViewerActivity() {
|
||||
override fun viewFile() {
|
||||
binding = ActivityImageViewerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.hide()
|
||||
showPartialSystemUi()
|
||||
applyNavigationBarMargin(binding.root)
|
||||
handler = Handler(mainLooper)
|
||||
binding.imageViewer.setOnInteractionListener(object : ZoomableImageView.OnInteractionListener {
|
||||
override fun onSingleTap(event: MotionEvent?) {
|
||||
handler.removeCallbacks(hideUI)
|
||||
if (binding.actionButtons.visibility == View.GONE) {
|
||||
binding.actionButtons.visibility = View.VISIBLE
|
||||
binding.actionBar.visibility = View.VISIBLE
|
||||
binding.topBar.visibility = View.VISIBLE
|
||||
showPartialSystemUi()
|
||||
handler.postDelayed(hideUI, hideDelay)
|
||||
} else {
|
||||
hideUI.run()
|
||||
@ -88,21 +101,21 @@ class ImageViewer: FileViewerActivity() {
|
||||
}
|
||||
})
|
||||
binding.imageDelete.setOnClickListener {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.warning)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
createPlaylist() //be sure the playlist is created before deleting if there is only one image
|
||||
if (gocryptfsVolume.removeFile(filePath)) {
|
||||
if (encryptedVolume.deleteFile(filePath)) {
|
||||
playlistNext(true)
|
||||
refreshPlaylist()
|
||||
if (mappedPlaylist.size == 0) { //deleted all images of the playlist
|
||||
goBackToExplorer()
|
||||
} else {
|
||||
loadImage()
|
||||
loadImage(true)
|
||||
}
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.remove_failed, fileName))
|
||||
@ -117,7 +130,7 @@ class ImageViewer: FileViewerActivity() {
|
||||
binding.imageButtonSlideshow.setOnClickListener {
|
||||
if (!slideshowActive){
|
||||
slideshowActive = true
|
||||
handler.postDelayed(slideshowNext, ConstValues.slideshow_delay)
|
||||
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
hideUI.run()
|
||||
Toast.makeText(this, R.string.slideshow_started, Toast.LENGTH_SHORT).show()
|
||||
@ -137,25 +150,39 @@ class ImageViewer: FileViewerActivity() {
|
||||
swipeImage(-1F)
|
||||
}
|
||||
}
|
||||
binding.imageRotateRight.setOnClickListener {
|
||||
rotationAngle += 90
|
||||
rotateImage()
|
||||
binding.imageRotateRight.setOnClickListener { onClickRotate(90f) }
|
||||
binding.imageRotateLeft.setOnClickListener { onClickRotate(-90f) }
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
if (slideshowActive) {
|
||||
stopSlideshow()
|
||||
} else {
|
||||
askSaveRotation {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.imageRotateLeft.setOnClickListener {
|
||||
rotationAngle -= 90
|
||||
rotateImage()
|
||||
}
|
||||
loadImage()
|
||||
loadImage(false)
|
||||
handler.postDelayed(hideUI, hideDelay)
|
||||
}
|
||||
|
||||
private fun loadImage(){
|
||||
loadWholeFile(filePath)?.let {
|
||||
glideImage = Glide.with(this).load(it)
|
||||
glideImage.into(binding.imageViewer)
|
||||
fileName = File(filePath).name
|
||||
binding.textFilename.text = fileName
|
||||
rotationAngle = 0F
|
||||
private fun loadImage(newImage: Boolean) {
|
||||
fileName = File(filePath).name
|
||||
binding.textFilename.text = fileName
|
||||
if (newImage || imageViewModel.imageBytes == null) {
|
||||
loadWholeFile(filePath) {
|
||||
imageViewModel.imageBytes = it
|
||||
requestBuilder = Glide.with(this).load(it)
|
||||
requestBuilder?.into(binding.imageViewer)
|
||||
imageViewModel.rotationAngle = 0f
|
||||
}
|
||||
} else {
|
||||
requestBuilder = Glide.with(this).load(imageViewModel.imageBytes)
|
||||
if (imageViewModel.rotationAngle.mod(360f) != 0f) {
|
||||
rotateImage()
|
||||
} else {
|
||||
requestBuilder?.into(binding.imageViewer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,14 +192,20 @@ class ImageViewer: FileViewerActivity() {
|
||||
handler.postDelayed(hideUI, hideDelay)
|
||||
}
|
||||
|
||||
private fun onClickRotate(angle: Float) {
|
||||
imageViewModel.rotationAngle += angle
|
||||
binding.imageViewer.restoreZoomNormal()
|
||||
rotateImage()
|
||||
}
|
||||
|
||||
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false){
|
||||
playlistNext(deltaX < 0)
|
||||
loadImage()
|
||||
if (slideshowActive){
|
||||
loadImage(true)
|
||||
if (slideshowActive) {
|
||||
if (!slideshowSwipe) { //reset slideshow delay if user swipes
|
||||
handler.removeCallbacks(slideshowNext)
|
||||
}
|
||||
handler.postDelayed(slideshowNext, ConstValues.slideshow_delay)
|
||||
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,36 +215,31 @@ class ImageViewer: FileViewerActivity() {
|
||||
Toast.makeText(this, R.string.slideshow_stopped, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (slideshowActive){
|
||||
stopSlideshow()
|
||||
} else {
|
||||
askSaveRotation { super.onBackPressed() }
|
||||
}
|
||||
}
|
||||
class OrientationTransformation(private val orientation: Float): BitmapTransformation() {
|
||||
|
||||
class RotateTransformation(private val imageViewer: ImageViewer): BitmapTransformation() {
|
||||
lateinit var bitmap: Bitmap
|
||||
|
||||
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap? {
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(imageViewer.rotationAngle)
|
||||
imageViewer.rotatedBitmap = Bitmap.createBitmap(toTransform, 0, 0, toTransform.width, toTransform.height, matrix, true)
|
||||
return imageViewer.rotatedBitmap
|
||||
return Bitmap.createBitmap(toTransform, 0, 0, toTransform.width, toTransform.height, Matrix().apply {
|
||||
postRotate(orientation)
|
||||
}, true).also {
|
||||
bitmap = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||
messageDigest.update("rotate${imageViewer.rotationAngle}".toByteArray())
|
||||
messageDigest.update("rotate$orientation".toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotateImage(){
|
||||
binding.imageViewer.restoreZoomNormal()
|
||||
glideImage.transform(RotateTransformation(this)).into(binding.imageViewer)
|
||||
private fun rotateImage() {
|
||||
orientationTransformation = OrientationTransformation(imageViewModel.rotationAngle)
|
||||
requestBuilder?.transform(orientationTransformation)?.into(binding.imageViewer)
|
||||
}
|
||||
|
||||
private fun askSaveRotation(callback: () -> Unit){
|
||||
if (rotationAngle%360 != 0f && !slideshowActive){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
if (imageViewModel.rotationAngle.mod(360f) != 0f && !slideshowActive) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.ask_save_img_rotated)
|
||||
@ -219,18 +247,18 @@ class ImageViewer: FileViewerActivity() {
|
||||
.setNeutralButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.yes) { _, _ ->
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
if (rotatedBitmap?.compress(
|
||||
if (orientationTransformation?.bitmap?.compress(
|
||||
if (fileName.endsWith("png", true)){
|
||||
Bitmap.CompressFormat.PNG
|
||||
} else {
|
||||
Bitmap.CompressFormat.JPEG
|
||||
}, 100, outputStream) == true
|
||||
}, 90, outputStream) == true
|
||||
){
|
||||
if (gocryptfsVolume.importFile(ByteArrayInputStream(outputStream.toByteArray()), filePath)){
|
||||
if (encryptedVolume.importFile(ByteArrayInputStream(outputStream.toByteArray()), filePath)) {
|
||||
Toast.makeText(this, R.string.image_saved_successfully, Toast.LENGTH_SHORT).show()
|
||||
callback()
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.file_write_failed)
|
||||
@ -238,7 +266,7 @@ class ImageViewer: FileViewerActivity() {
|
||||
.show()
|
||||
}
|
||||
} else {
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.bitmap_compress_failed)
|
||||
|
@ -1,33 +1,43 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import android.view.WindowManager
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import sushi.hardcore.droidfs.ConstValues
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.VideoSize
|
||||
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.widgets.ColoredAlertDialogBuilder
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.io.File
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
abstract class MediaPlayer: FileViewerActivity() {
|
||||
private lateinit var player: SimpleExoPlayer
|
||||
private lateinit var player: ExoPlayer
|
||||
|
||||
override fun viewFile() {
|
||||
supportActionBar?.hide()
|
||||
initializePlayer()
|
||||
refreshFileName()
|
||||
}
|
||||
|
||||
abstract fun bindPlayer(player: SimpleExoPlayer)
|
||||
protected open fun onPlaylistIndexChanged() {}
|
||||
protected open fun onPlayerReady() {}
|
||||
abstract fun bindPlayer(player: ExoPlayer)
|
||||
abstract fun onNewFileName(fileName: String)
|
||||
protected open fun onVideoSizeChanged(width: Int, height: Int) {}
|
||||
|
||||
private fun createMediaSource(filePath: String): MediaSource {
|
||||
val dataSourceFactory = GocryptfsDataSource.Factory(gocryptfsVolume, filePath)
|
||||
val dataSourceFactory = EncryptedVolumeDataSource.Factory(encryptedVolume, filePath)
|
||||
return ProgressiveMediaSource.Factory(dataSourceFactory, DefaultExtractorsFactory())
|
||||
.createMediaSource(MediaItem.fromUri(ConstValues.fakeUri))
|
||||
.createMediaSource(MediaItem.fromUri(Constants.FAKE_URI))
|
||||
}
|
||||
|
||||
private fun initializePlayer(){
|
||||
player = SimpleExoPlayer.Builder(this).build()
|
||||
player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
|
||||
bindPlayer(player)
|
||||
createPlaylist()
|
||||
for (e in mappedPlaylist) {
|
||||
@ -37,13 +47,11 @@ abstract class MediaPlayer: FileViewerActivity() {
|
||||
player.seekToDefaultPosition(currentPlaylistIndex)
|
||||
player.playWhenReady = true
|
||||
player.addListener(object : Player.Listener{
|
||||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
onPlayerReady()
|
||||
}
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
onVideoSizeChanged(videoSize.width, videoSize.height)
|
||||
}
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
ColoredAlertDialogBuilder(this@MediaPlayer)
|
||||
CustomAlertDialogBuilder(this@MediaPlayer, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.playing_failed, error.errorCodeName))
|
||||
.setCancelable(false)
|
||||
@ -57,10 +65,11 @@ abstract class MediaPlayer: FileViewerActivity() {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
override fun onPositionDiscontinuity(reason: Int) {
|
||||
if (player.currentWindowIndex != currentPlaylistIndex) {
|
||||
playlistNext(player.currentWindowIndex == (currentPlaylistIndex+1)%mappedPlaylist.size)
|
||||
onPlaylistIndexChanged()
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
if (player.repeatMode != Player.REPEAT_MODE_ONE) {
|
||||
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % mappedPlaylist.size)
|
||||
refreshFileName()
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -73,4 +82,8 @@ abstract class MediaPlayer: FileViewerActivity() {
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshFileName() {
|
||||
onNewFileName(File(filePath).name)
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import app.grapheneos.pdfviewer.PdfViewer
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
|
||||
class PdfViewer: FileViewerActivity() {
|
||||
init {
|
||||
applyCustomTheme = false
|
||||
}
|
||||
private lateinit var pdfViewer: PdfViewer
|
||||
|
||||
override fun getFileType(): String {
|
||||
return "pdf"
|
||||
}
|
||||
|
||||
override fun viewFile() {
|
||||
pdfViewer = PdfViewer(this)
|
||||
val fileName = File(filePath).name
|
||||
title = fileName
|
||||
val fileSize = encryptedVolume.getAttr(filePath)?.size
|
||||
loadWholeFile(filePath, fileSize) {
|
||||
pdfViewer.loadPdf(ByteArrayInputStream(it), fileName, fileSize)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
pdfViewer.onCreateOptionMenu(menu)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
pdfViewer.onResume()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
pdfViewer.onDestroy()
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
return pdfViewer.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return pdfViewer.onOptionsItemSelected(item) || super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
@ -6,37 +6,34 @@ import android.text.TextWatcher
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.activity.addCallback
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.widgets.ColoredAlertDialogBuilder
|
||||
import java.io.ByteArrayInputStream
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.io.File
|
||||
|
||||
class TextEditor: FileViewerActivity() {
|
||||
private lateinit var fileName: String
|
||||
private lateinit var editor: EditText
|
||||
private lateinit var toolbar: Toolbar
|
||||
private lateinit var titleText: TextView
|
||||
private var changedSinceLastSave = false
|
||||
private var wordWrap = true
|
||||
override fun hideSystemUi() {
|
||||
//don't hide system ui
|
||||
}
|
||||
|
||||
override fun getFileType(): String {
|
||||
return "text"
|
||||
}
|
||||
|
||||
override fun viewFile() {
|
||||
loadWholeFile(filePath)?.let {
|
||||
fileName = File(filePath).name
|
||||
fileName = File(filePath).name
|
||||
title = fileName
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
loadWholeFile(filePath) {
|
||||
try {
|
||||
loadLayout(String(it))
|
||||
onBackPressedDispatcher.addCallback(this) {
|
||||
checkSaveAndExit()
|
||||
}
|
||||
} catch (e: OutOfMemoryError){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.outofmemoryerror_msg)
|
||||
.setCancelable(false)
|
||||
@ -51,11 +48,6 @@ class TextEditor: FileViewerActivity() {
|
||||
} else {
|
||||
setContentView(R.layout.activity_text_editor)
|
||||
}
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
setSupportActionBar(toolbar)
|
||||
title = ""
|
||||
titleText = findViewById(R.id.title_text)
|
||||
titleText.text = fileName
|
||||
editor = findViewById(R.id.text_editor)
|
||||
editor.setText(fileContent)
|
||||
editor.addTextChangedListener(object: TextWatcher {
|
||||
@ -67,7 +59,7 @@ class TextEditor: FileViewerActivity() {
|
||||
if (!changedSinceLastSave){
|
||||
changedSinceLastSave = true
|
||||
@SuppressLint("SetTextI18n")
|
||||
titleText.text = "*$fileName"
|
||||
title = "*$fileName"
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -75,25 +67,14 @@ class TextEditor: FileViewerActivity() {
|
||||
private fun save(): Boolean{
|
||||
var success = false
|
||||
val content = editor.text.toString().toByteArray()
|
||||
val handleID = gocryptfsVolume.openWriteMode(filePath)
|
||||
if (handleID != -1){
|
||||
val buff = ByteArrayInputStream(content)
|
||||
val fileHandle = encryptedVolume.openFileWriteMode(filePath)
|
||||
if (fileHandle != -1L) {
|
||||
var offset: Long = 0
|
||||
val ioBuffer = ByteArray(GocryptfsVolume.DefaultBS)
|
||||
var length: Int
|
||||
while (buff.read(ioBuffer).also { length = it } > 0) {
|
||||
val written = gocryptfsVolume.writeFile(handleID, offset, ioBuffer, length).toLong()
|
||||
if (written == length.toLong()) {
|
||||
offset += written
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
while (offset < content.size && encryptedVolume.write(fileHandle, offset, content, offset, content.size.toLong()).also { offset += it } > 0) {}
|
||||
if (offset == content.size.toLong()){
|
||||
success = gocryptfsVolume.truncate(handleID, offset)
|
||||
success = encryptedVolume.truncate(filePath, offset)
|
||||
}
|
||||
gocryptfsVolume.closeFile(handleID)
|
||||
buff.close()
|
||||
encryptedVolume.closeFile(fileHandle)
|
||||
}
|
||||
if (success){
|
||||
Toast.makeText(this, getString(R.string.file_saved), Toast.LENGTH_SHORT).show()
|
||||
@ -105,7 +86,7 @@ class TextEditor: FileViewerActivity() {
|
||||
|
||||
private fun checkSaveAndExit(){
|
||||
if (changedSinceLastSave){
|
||||
ColoredAlertDialogBuilder(this)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.ask_save)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
@ -122,7 +103,6 @@ class TextEditor: FileViewerActivity() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.text_editor, menu)
|
||||
toolbar.setNavigationIcon(R.drawable.icon_arrow_back)
|
||||
menu.findItem(R.id.word_wrap).isChecked = wordWrap
|
||||
return true
|
||||
}
|
||||
@ -135,7 +115,7 @@ class TextEditor: FileViewerActivity() {
|
||||
R.id.menu_save -> {
|
||||
if (save()){
|
||||
changedSinceLastSave = false
|
||||
titleText.text = fileName
|
||||
title = fileName
|
||||
}
|
||||
}
|
||||
R.id.word_wrap -> {
|
||||
@ -147,8 +127,4 @@ class TextEditor: FileViewerActivity() {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
checkSaveAndExit()
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,10 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer
|
||||
import android.content.res.Configuration
|
||||
import android.view.View
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import sushi.hardcore.droidfs.databinding.ActivityVideoPlayerBinding
|
||||
|
||||
class VideoPlayer: MediaPlayer() {
|
||||
@ -14,20 +17,45 @@ class VideoPlayer: MediaPlayer() {
|
||||
override fun viewFile() {
|
||||
binding = ActivityVideoPlayerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
applyNavigationBarMargin(binding.root)
|
||||
binding.videoPlayer.doubleTapOverlay = binding.doubleTapOverlay
|
||||
binding.videoPlayer.setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { visibility ->
|
||||
binding.topBar.visibility = visibility
|
||||
if (visibility == View.VISIBLE) {
|
||||
showPartialSystemUi()
|
||||
} else {
|
||||
hideSystemUi()
|
||||
}
|
||||
})
|
||||
binding.rotateButton.setOnClickListener {
|
||||
requestedOrientation =
|
||||
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||
}
|
||||
}
|
||||
super.viewFile()
|
||||
}
|
||||
|
||||
override fun bindPlayer(player: SimpleExoPlayer) {
|
||||
override fun bindPlayer(player: ExoPlayer) {
|
||||
binding.videoPlayer.player = player
|
||||
}
|
||||
|
||||
override fun onNewFileName(fileName: String) {
|
||||
binding.textFileName.text = fileName
|
||||
}
|
||||
|
||||
override fun getFileType(): String {
|
||||
return "video"
|
||||
}
|
||||
|
||||
override fun onPlayerReady() {
|
||||
override fun onVideoSizeChanged(width: Int, height: Int) {
|
||||
if (firstPlay && autoFit) {
|
||||
requestedOrientation = if (binding.videoPlayer.width < binding.videoPlayer.height) ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||
requestedOrientation = if (width < height)
|
||||
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
|
||||
else
|
||||
ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||
firstPlay = false
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,163 @@
|
||||
package sushi.hardcore.droidfs.filesystems
|
||||
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
|
||||
class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("cryfs_jni")
|
||||
}
|
||||
|
||||
const val CONFIG_FILE_NAME = "cryfs.config"
|
||||
|
||||
private external fun nativeInit(
|
||||
baseDir: String,
|
||||
localStateDir: String,
|
||||
password: ByteArray?,
|
||||
givenHash: ByteArray?,
|
||||
returnedHash: ObjRef<ByteArray?>?,
|
||||
createBaseDir: Boolean,
|
||||
cipher: String?,
|
||||
errorCode: ObjRef<Int?>,
|
||||
): Long
|
||||
private external fun nativeChangeEncryptionKey(
|
||||
baseDir: String,
|
||||
localStateDir: String,
|
||||
currentPassword: ByteArray?,
|
||||
givenHash: ByteArray?,
|
||||
newPassword: ByteArray,
|
||||
returnedHash: ObjRef<ByteArray?>?
|
||||
): Boolean
|
||||
private external fun nativeCreate(fusePtr: Long, path: String, mode: Int): Long
|
||||
private external fun nativeOpen(fusePtr: Long, path: String, flags: Int): Long
|
||||
private external fun nativeRead(fusePtr: Long, fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int
|
||||
private external fun nativeWrite(fusePtr: Long, fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int
|
||||
private external fun nativeTruncate(fusePtr: Long, path: String, size: Long): Boolean
|
||||
private external fun nativeDeleteFile(fusePtr: Long, path: String): Boolean
|
||||
private external fun nativeCloseFile(fusePtr: Long, fileHandle: Long): Boolean
|
||||
private external fun nativeReadDir(fusePtr: Long, path: String): MutableList<ExplorerElement>?
|
||||
private external fun nativeMkdir(fusePtr: Long, path: String, mode: Int): Boolean
|
||||
private external fun nativeRmdir(fusePtr: Long, path: String): Boolean
|
||||
private external fun nativeGetAttr(fusePtr: Long, path: String): Stat?
|
||||
private external fun nativeRename(fusePtr: Long, srcPath: String, dstPath: String): Boolean
|
||||
private external fun nativeClose(fusePtr: Long)
|
||||
private external fun nativeIsClosed(fusePtr: Long): Boolean
|
||||
|
||||
fun getLocalStateDir(filesDir: String): String {
|
||||
return PathUtils.pathJoin(filesDir, Constants.CRYFS_LOCAL_STATE_DIR)
|
||||
}
|
||||
|
||||
private fun init(
|
||||
baseDir: String,
|
||||
localStateDir: String,
|
||||
password: ByteArray?,
|
||||
givenHash: ByteArray?,
|
||||
returnedHash: ObjRef<ByteArray?>?,
|
||||
createBaseDir: Boolean,
|
||||
cipher: String?
|
||||
): InitResult {
|
||||
val errorCode = ObjRef<Int?>(null)
|
||||
val fusePtr = nativeInit(baseDir, localStateDir, password, givenHash, returnedHash, createBaseDir, cipher, errorCode)
|
||||
val result = InitResult.Builder()
|
||||
if (fusePtr == 0L) {
|
||||
result.errorCode = errorCode.value ?: 0
|
||||
result.errorStringId = when (errorCode.value) {
|
||||
// Values from src/cryfs/impl/ErrorCodes.h
|
||||
11 -> {
|
||||
result.worthRetry = true
|
||||
R.string.wrong_password
|
||||
}
|
||||
16 -> R.string.inaccessible_base_dir
|
||||
19 -> R.string.config_load_error
|
||||
20 -> R.string.filesystem_id_changed
|
||||
else -> 0
|
||||
}
|
||||
} else {
|
||||
result.volume = CryfsVolume(fusePtr)
|
||||
}
|
||||
return result.build()
|
||||
}
|
||||
|
||||
fun create(baseDir: String, localStateDir: String, password: ByteArray, returnedHash: ObjRef<ByteArray?>?, cipher: String?): EncryptedVolume? {
|
||||
return init(baseDir, localStateDir, password, null, returnedHash, true, cipher).volume
|
||||
}
|
||||
|
||||
fun init(baseDir: String, localStateDir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ObjRef<ByteArray?>?): InitResult {
|
||||
return init(baseDir, localStateDir, password, givenHash, returnedHash, false, null)
|
||||
}
|
||||
|
||||
fun changePassword(
|
||||
baseDir: String, filesDir: String, currentPassword: ByteArray?,
|
||||
givenHash: ByteArray?,
|
||||
newPassword: ByteArray,
|
||||
returnedHash: ObjRef<ByteArray?>?
|
||||
): Boolean {
|
||||
return nativeChangeEncryptionKey(baseDir, getLocalStateDir(filesDir), currentPassword, givenHash, newPassword, returnedHash)
|
||||
}
|
||||
}
|
||||
|
||||
override fun openFileReadMode(path: String): Long {
|
||||
return nativeOpen(fusePtr, path, 0)
|
||||
}
|
||||
|
||||
override fun openFileWriteMode(path: String): Long {
|
||||
val fileHandle = nativeOpen(fusePtr, path, 0)
|
||||
return if (fileHandle == -1L) {
|
||||
nativeCreate(fusePtr, path, 0)
|
||||
} else {
|
||||
fileHandle
|
||||
}
|
||||
}
|
||||
|
||||
override fun read(fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int {
|
||||
return nativeRead(fusePtr, fileHandle, fileOffset, buffer, dstOffset, length)
|
||||
}
|
||||
|
||||
override fun write(fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int {
|
||||
return nativeWrite(fusePtr, fileHandle, fileOffset, buffer, srcOffset, length)
|
||||
}
|
||||
|
||||
override fun truncate(path: String, size: Long): Boolean {
|
||||
return nativeTruncate(fusePtr, path, size)
|
||||
}
|
||||
|
||||
override fun closeFile(fileHandle: Long): Boolean {
|
||||
return nativeCloseFile(fusePtr, fileHandle)
|
||||
}
|
||||
|
||||
override fun deleteFile(path: String): Boolean {
|
||||
return nativeDeleteFile(fusePtr, path)
|
||||
}
|
||||
|
||||
override fun readDir(path: String): MutableList<ExplorerElement>? {
|
||||
return nativeReadDir(fusePtr, path)
|
||||
}
|
||||
|
||||
override fun mkdir(path: String): Boolean {
|
||||
return nativeMkdir(fusePtr, path, Stat.S_IFDIR)
|
||||
}
|
||||
|
||||
override fun rmdir(path: String): Boolean {
|
||||
return nativeRmdir(fusePtr, path)
|
||||
}
|
||||
|
||||
override fun getAttr(path: String): Stat? {
|
||||
return nativeGetAttr(fusePtr, path)
|
||||
}
|
||||
|
||||
override fun rename(srcPath: String, dstPath: String): Boolean {
|
||||
return nativeRename(fusePtr, srcPath, dstPath)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
return nativeClose(fusePtr)
|
||||
}
|
||||
|
||||
override fun isClosed(): Boolean {
|
||||
return nativeIsClosed(fusePtr)
|
||||
}
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
package sushi.hardcore.droidfs.filesystems
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.VolumeData
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.Observable
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
|
||||
|
||||
interface Observer {
|
||||
fun onClose()
|
||||
}
|
||||
|
||||
class InitResult(
|
||||
val errorCode: Int,
|
||||
val errorStringId: Int,
|
||||
val worthRetry: Boolean,
|
||||
val volume: EncryptedVolume?,
|
||||
) {
|
||||
class Builder {
|
||||
var errorCode = 0
|
||||
var errorStringId = 0
|
||||
var worthRetry = false
|
||||
var volume: EncryptedVolume? = null
|
||||
|
||||
fun build() = InitResult(errorCode, errorStringId, worthRetry, volume)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val GOCRYPTFS_VOLUME_TYPE: Byte = 0
|
||||
const val CRYFS_VOLUME_TYPE: Byte = 1
|
||||
|
||||
/**
|
||||
* Get the type of a volume.
|
||||
*
|
||||
* @return The volume type or -1 if the path is not recognized as a volume
|
||||
*/
|
||||
fun getVolumeType(path: String): Byte {
|
||||
return if (File(path, GocryptfsVolume.CONFIG_FILE_NAME).isFile) {
|
||||
GOCRYPTFS_VOLUME_TYPE
|
||||
} else if (File(path, CryfsVolume.CONFIG_FILE_NAME).isFile) {
|
||||
CRYFS_VOLUME_TYPE
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
|
||||
fun init(
|
||||
volume: VolumeData,
|
||||
filesDir: String,
|
||||
password: ByteArray?,
|
||||
givenHash: ByteArray?,
|
||||
returnedHash: ObjRef<ByteArray?>?
|
||||
): InitResult {
|
||||
return when (volume.type) {
|
||||
GOCRYPTFS_VOLUME_TYPE -> {
|
||||
GocryptfsVolume.init(
|
||||
volume.getFullPath(filesDir),
|
||||
password,
|
||||
givenHash,
|
||||
returnedHash?.apply {
|
||||
value = ByteArray(GocryptfsVolume.KeyLen)
|
||||
}?.value
|
||||
)
|
||||
}
|
||||
CRYFS_VOLUME_TYPE -> {
|
||||
CryfsVolume.init(volume.getFullPath(filesDir), CryfsVolume.getLocalStateDir(filesDir), password, givenHash, returnedHash)
|
||||
}
|
||||
else -> throw invalidVolumeType()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalidVolumeType(): java.lang.RuntimeException {
|
||||
return RuntimeException("Invalid volume type")
|
||||
}
|
||||
}
|
||||
|
||||
abstract fun openFileReadMode(path: String): Long
|
||||
abstract fun openFileWriteMode(path: String): Long
|
||||
abstract fun read(fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int
|
||||
abstract fun write(fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int
|
||||
abstract fun closeFile(fileHandle: Long): Boolean
|
||||
// Due to gocryptfs internals, truncate requires the file to be open before it is called
|
||||
abstract fun truncate(path: String, size: Long): Boolean
|
||||
abstract fun deleteFile(path: String): Boolean
|
||||
abstract fun readDir(path: String): MutableList<ExplorerElement>?
|
||||
abstract fun mkdir(path: String): Boolean
|
||||
abstract fun rmdir(path: String): Boolean
|
||||
abstract fun getAttr(path: String): Stat?
|
||||
abstract fun rename(srcPath: String, dstPath: String): Boolean
|
||||
protected abstract fun close()
|
||||
abstract fun isClosed(): Boolean
|
||||
|
||||
fun closeVolume() {
|
||||
observers.forEach { it.onClose() }
|
||||
close()
|
||||
}
|
||||
|
||||
fun pathExists(path: String): Boolean {
|
||||
return getAttr(path) != null
|
||||
}
|
||||
|
||||
fun exportFile(fileHandle: Long, os: OutputStream): Boolean {
|
||||
var offset: Long = 0
|
||||
val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
|
||||
var length: Int
|
||||
while (read(fileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it } > 0) {
|
||||
os.write(ioBuffer, 0, length)
|
||||
offset += length.toLong()
|
||||
}
|
||||
os.close()
|
||||
return true
|
||||
}
|
||||
|
||||
fun exportFile(src_path: String, os: OutputStream): Boolean {
|
||||
var success = false
|
||||
val srcfileHandle = openFileReadMode(src_path)
|
||||
if (srcfileHandle != -1L) {
|
||||
success = exportFile(srcfileHandle, os)
|
||||
closeFile(srcfileHandle)
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
fun exportFile(src_path: String, dst_path: String): Boolean {
|
||||
return exportFile(src_path, FileOutputStream(dst_path))
|
||||
}
|
||||
|
||||
fun exportFile(context: Context, src_path: String, output_path: Uri): Boolean {
|
||||
val os = context.contentResolver.openOutputStream(output_path)
|
||||
if (os != null) {
|
||||
return exportFile(src_path, os)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun importFile(inputStream: InputStream, dst_path: String): Boolean {
|
||||
val dstfileHandle = openFileWriteMode(dst_path)
|
||||
if (dstfileHandle != -1L) {
|
||||
var success = true
|
||||
var offset: Long = 0
|
||||
val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
|
||||
var length: Long
|
||||
while (inputStream.read(ioBuffer).also { length = it.toLong() } > 0) {
|
||||
val written = write(dstfileHandle, offset, ioBuffer, 0, length).toLong()
|
||||
if (written == length) {
|
||||
offset += written
|
||||
} else {
|
||||
success = false
|
||||
break
|
||||
}
|
||||
}
|
||||
truncate(dst_path, offset)
|
||||
closeFile(dstfileHandle)
|
||||
inputStream.close()
|
||||
return success
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun importFile(context: Context, src_uri: Uri, dst_path: String): Boolean {
|
||||
val inputStream = context.contentResolver.openInputStream(src_uri)
|
||||
if (inputStream != null) {
|
||||
return importFile(inputStream, dst_path)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun loadWholeFile(fullPath: String, size: Long? = null, maxSize: Long? = null): Pair<ByteArray?, Int> {
|
||||
val fileSize = size ?: getAttr(fullPath)?.size ?: -1
|
||||
return if (fileSize >= 0) {
|
||||
maxSize?.let {
|
||||
if (fileSize > it) {
|
||||
return Pair(null, 0)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val fileBuff = ByteArray(fileSize.toInt())
|
||||
val fileHandle = openFileReadMode(fullPath)
|
||||
if (fileHandle == -1L) {
|
||||
Pair(null, 3)
|
||||
} else {
|
||||
var offset: Long = 0
|
||||
while (offset < fileSize && read(fileHandle, offset, fileBuff, offset, fileSize-offset).also { offset += it } > 0) {}
|
||||
closeFile(fileHandle)
|
||||
if (offset == fileBuff.size.toLong()) {
|
||||
Pair(fileBuff, 0)
|
||||
} else {
|
||||
Pair(null, 4)
|
||||
}
|
||||
}
|
||||
} catch (e: OutOfMemoryError) {
|
||||
Pair(null, 2)
|
||||
}
|
||||
} else {
|
||||
Pair(null, 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun recursiveMapFiles(rootPath: String): MutableList<ExplorerElement>? {
|
||||
val result = mutableListOf<ExplorerElement>()
|
||||
val explorerElements = readDir(rootPath) ?: return null
|
||||
result.addAll(explorerElements)
|
||||
for (e in explorerElements) {
|
||||
if (e.isDirectory) {
|
||||
result.addAll(recursiveMapFiles(e.fullPath) ?: return null)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
@ -0,0 +1,158 @@
|
||||
package sushi.hardcore.droidfs.filesystems
|
||||
|
||||
import android.util.Log
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import kotlin.math.min
|
||||
|
||||
class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
|
||||
private external fun native_close(sessionID: Int)
|
||||
private external fun native_is_closed(sessionID: Int): Boolean
|
||||
private external fun native_list_dir(sessionID: Int, dir_path: String): MutableList<ExplorerElement>?
|
||||
private external fun native_open_read_mode(sessionID: Int, file_path: String): Int
|
||||
private external fun native_open_write_mode(sessionID: Int, file_path: String, mode: Int): Int
|
||||
private external fun native_read_file(sessionID: Int, handleID: Int, fileOffset: Long, buff: ByteArray, dstOffset: Long, length: Int): Int
|
||||
private external fun native_write_file(sessionID: Int, handleID: Int, fileOffset: Long, buff: ByteArray, srcOffset: Long, length: Int): Int
|
||||
private external fun native_truncate(sessionID: Int, path: String, offset: Long): Boolean
|
||||
private external fun native_close_file(sessionID: Int, handleID: Int)
|
||||
private external fun native_remove_file(sessionID: Int, file_path: String): Boolean
|
||||
private external fun native_mkdir(sessionID: Int, dir_path: String, mode: Int): Boolean
|
||||
private external fun native_rmdir(sessionID: Int, dir_path: String): Boolean
|
||||
private external fun native_get_attr(sessionID: Int, file_path: String): Stat?
|
||||
private external fun native_rename(sessionID: Int, old_path: String, new_path: String): Boolean
|
||||
|
||||
companion object {
|
||||
const val KeyLen = 32
|
||||
private const val ScryptDefaultLogN = 16
|
||||
private const val VOLUME_CREATOR = "DroidFS"
|
||||
private const val MAX_KERNEL_WRITE = 128*1024
|
||||
const val CONFIG_FILE_NAME = "gocryptfs.conf"
|
||||
private external fun nativeCreateVolume(
|
||||
root_cipher_dir: String,
|
||||
password: ByteArray,
|
||||
plainTextNames: Boolean,
|
||||
xchacha: Int,
|
||||
logN: Int,
|
||||
creator: String,
|
||||
returnedHash: ByteArray?,
|
||||
): Int
|
||||
private external fun nativeInit(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): Int
|
||||
external fun changePassword(
|
||||
root_cipher_dir: String,
|
||||
currentPassword: ByteArray?,
|
||||
givenHash: ByteArray?,
|
||||
newPassword: ByteArray,
|
||||
returnedHash: ByteArray?
|
||||
): Boolean
|
||||
|
||||
fun createAndOpenVolume(
|
||||
root_cipher_dir: String,
|
||||
password: ByteArray,
|
||||
plainTextNames: Boolean,
|
||||
xchacha: Int,
|
||||
returnedHash: ByteArray?,
|
||||
volume: ObjRef<EncryptedVolume?>
|
||||
): Boolean {
|
||||
return when (val result = nativeCreateVolume(
|
||||
root_cipher_dir,
|
||||
password,
|
||||
plainTextNames,
|
||||
xchacha,
|
||||
ScryptDefaultLogN,
|
||||
VOLUME_CREATOR,
|
||||
returnedHash,
|
||||
)) {
|
||||
-1 -> {
|
||||
Log.e("gocryptfs", "Failed to open volume after creation")
|
||||
true
|
||||
}
|
||||
-2 -> false
|
||||
else -> {
|
||||
volume.value = GocryptfsVolume(result)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun init(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): InitResult {
|
||||
val sessionId = nativeInit(root_cipher_dir, password, givenHash, returnedHash)
|
||||
val result = InitResult.Builder()
|
||||
if (sessionId < 0) {
|
||||
result.errorCode = sessionId
|
||||
result.errorStringId = when (sessionId) {
|
||||
-1 -> R.string.config_load_error
|
||||
-2 -> {
|
||||
result.worthRetry = true
|
||||
R.string.wrong_password
|
||||
}
|
||||
else -> 0
|
||||
}
|
||||
} else {
|
||||
result.volume = GocryptfsVolume(sessionId)
|
||||
}
|
||||
return result.build()
|
||||
}
|
||||
|
||||
init {
|
||||
System.loadLibrary("gocryptfs_jni")
|
||||
}
|
||||
}
|
||||
|
||||
override fun openFileReadMode(path: String): Long {
|
||||
return native_open_read_mode(sessionID, path).toLong()
|
||||
}
|
||||
|
||||
override fun openFileWriteMode(path: String): Long {
|
||||
return native_open_write_mode(sessionID, path, 384).toLong() // 0600
|
||||
}
|
||||
|
||||
override fun read(fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int {
|
||||
return native_read_file(sessionID, fileHandle.toInt(), fileOffset, buffer, dstOffset, min(length.toInt(), MAX_KERNEL_WRITE))
|
||||
}
|
||||
|
||||
override fun readDir(path: String): MutableList<ExplorerElement>? {
|
||||
return native_list_dir(sessionID, path)
|
||||
}
|
||||
|
||||
override fun getAttr(path: String): Stat? {
|
||||
return native_get_attr(sessionID, path)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
native_close(sessionID)
|
||||
}
|
||||
|
||||
override fun isClosed(): Boolean {
|
||||
return native_is_closed(sessionID)
|
||||
}
|
||||
|
||||
override fun mkdir(path: String): Boolean {
|
||||
return native_mkdir(sessionID, path, 448) // 0700
|
||||
}
|
||||
|
||||
override fun rmdir(path: String): Boolean {
|
||||
return native_rmdir(sessionID, path)
|
||||
}
|
||||
|
||||
override fun closeFile(fileHandle: Long): Boolean {
|
||||
native_close_file(sessionID, fileHandle.toInt())
|
||||
return true
|
||||
}
|
||||
|
||||
override fun write(fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int {
|
||||
return native_write_file(sessionID, fileHandle.toInt(), fileOffset, buffer, srcOffset, min(length.toInt(), MAX_KERNEL_WRITE))
|
||||
}
|
||||
|
||||
override fun truncate(path: String, size: Long): Boolean {
|
||||
return native_truncate(sessionID, path, size)
|
||||
}
|
||||
|
||||
override fun deleteFile(path: String): Boolean {
|
||||
return native_remove_file(sessionID, path)
|
||||
}
|
||||
|
||||
override fun rename(srcPath: String, dstPath: String): Boolean {
|
||||
return native_rename(sessionID, srcPath, dstPath)
|
||||
}
|
||||
}
|
17
app/src/main/java/sushi/hardcore/droidfs/filesystems/Stat.kt
Normal file
17
app/src/main/java/sushi/hardcore/droidfs/filesystems/Stat.kt
Normal file
@ -0,0 +1,17 @@
|
||||
package sushi.hardcore.droidfs.filesystems
|
||||
|
||||
class Stat(mode: Int, var size: Long, val mTime: Long) {
|
||||
companion object {
|
||||
private const val S_IFMT = 0xF000
|
||||
const val S_IFDIR = 0x4000
|
||||
const val S_IFREG = 0x8000
|
||||
const val S_IFLNK = 0xA000
|
||||
const val PARENT_FOLDER_TYPE = 0xE000
|
||||
|
||||
fun parentFolderStat(): Stat {
|
||||
return Stat(PARENT_FOLDER_TYPE, -1, -1)
|
||||
}
|
||||
}
|
||||
|
||||
val type = mode and S_IFMT
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user