Compare commits

..

9 Commits

Author SHA1 Message Date
d2327052dc
Finalize libcryfs 2022-06-26 13:15:51 +02:00
a92695f8d5
Cleaning 2022-06-25 22:10:37 +02:00
a8fb5960f2
Build documentation 2022-06-25 19:25:15 +02:00
da7cb57b56
Better modular build 2022-06-25 19:24:26 +02:00
43a3f72935
Modular build 2022-06-24 19:55:50 +02:00
53f4e3507b
Fix libcryfs build 2022-06-22 22:33:47 +02:00
b2ab69c8f2 CryFS volume creation & bug fixes 2022-06-19 17:59:24 +02:00
b5a8b02c5c Add libcryfs submodule 2022-06-18 21:48:16 +02:00
dbd04848bd libcryfs genesis 2022-06-18 21:13:16 +02:00
213 changed files with 5672 additions and 18921 deletions

3
.gitmodules vendored
View File

@ -7,6 +7,3 @@
[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

View File

@ -1,21 +1,22 @@
# 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.
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. 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
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 by [email](mailto:hardcore.sushi@disroot.org) 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
$ sudo apt-get install openjdk-11-jdk-headless build-essential pkg-config git gnupg2 wget apksigner
```
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.
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://developer.android.com/ndk/downloads) (r23 versions are recommended).
If you want a support for Gocryptfs volumes, you need to install [Go](https://golang.org/doc/install):
If you want a support for Gocryptfs volumes, you must install [Go](https://golang.org/doc/install) and libssl:
```
$ sudo apt-get install golang-go
$ sudo apt-get install golang-go libssl-dev
```
For CryFS support, you need [Python](https://www.python.org):
```
$ sudo apt-get install python3
```
The code should be authenticated before being built. To verify the signatures, you will need my PGP key:
```
@ -27,7 +28,7 @@ Email: `Hardcore Sushi <hardcore.sushi@disroot.org>`
# Download sources
Download DroidFS source code:
```
$ git clone --depth=1 https://forge.chapril.org/hardcoresushi/DroidFS.git
$ git clone --depth=1 https://github.com/hardcore-sushi/DroidFS.git
```
Verify sources:
```
@ -38,17 +39,31 @@ __Don't continue if the verification fails!__
Initialize submodules:
```
$ git submodule update --init
$ git submodule update --depth=1 --init
```
If you want Gocryptfs support, initliaze libgocryptfs submodules:
[FFmpeg](https://ffmpeg.org) is needed to record encrypted video:
```
$ cd app/libgocryptfs
$ git submodule update --init
$ cd app/ffmpeg
$ git clone --depth=1 https://git.ffmpeg.org/ffmpeg.git
```
If you want CryFS support, initialize libcryfs submodules:
If you want Gocryptfs support, you need to download OpenSSL:
```
$ cd ../libgocryptfs
$ wget https://www.openssl.org/source/openssl-1.1.1p.tar.gz
```
Verify OpenSSL signature:
```
$ wget https://www.openssl.org/source/openssl-1.1.1p.tar.gz.asc
$ gpg --verify openssl-1.1.1p.tar.gz.asc openssl-1.1.1p.tar.gz
```
Continue **ONLY** if the signature is **VALID**.
```
$ tar -xzf openssl-1.1.1p.tar.gz
```
If you want CryFS support, initialize libcryfs:
```
$ cd app/libcryfs
$ git submodule update --init
$ git submodule update --depth=1 --init
```
# Build
@ -56,33 +71,31 @@ Retrieve your Android NDK installation path, usually something like `/home/\<use
```
$ 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>]
$ ./build.sh ffmpeg
```
## libgocryptfs
This step is only required if you want Gocryptfs support.
```
$ cd app/libgocryptfs
$ ./build.sh [<ABI>]
```
$ OPENSSL_PATH="./openssl-1.1.1p" ./build.sh
```
## Compile APKs
Gradle build libgocryptfs and libcryfs by default.
To build DroidFS without Gocryptfs support, run:
```
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableGocryptfs=true
$ ./gradlew assembleRelease -PdisableGocryptfs=true
```
To build DroidFS without CryFS support, run:
```
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableCryFS=true
$ ./gradlew assembleRelease -PdisableCryFS=true
```
If you want to build DroidFS with support for both Gocryptfs and CryFS, just run:
```
$ ./gradlew assembleRelease [-Pabi=<ABI>]
$ ./gradlew assembleRelease
```
# Sign APKs

View File

@ -1,20 +0,0 @@
-----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-----

110
README.md
View File

@ -1,81 +1,52 @@
# DroidFS
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.
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 supports [gocryptfs](https://github.com/rfjakob/gocryptfs) and [CryFS](https://github.com/cryfs/cryfs) (alpha).
<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/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">
<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">
</p>
# Support
The creator of DroidFS works as a freelance developer and privacy consultant. I am currently looking for new clients! If you are interested, take a look at the [website](https://arkensys.dedyn.io). Alternatively, you can directly support DroidFS by making a [donation](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/DONATE.txt).
Thank you so much ❤️.
# Disclaimer
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
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.
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.
<ul>
<li><b>Allow screenshots:</b>
<li><h4>Allow screenshots:</h4>
Disable the secure flag of DroidFS activities. This will allow you to take screenshots from the app, but will also allow other apps to record the screen while using DroidFS.
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>
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>
</ul>
⁽¹⁾: 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.
* 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.
# 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 this repository.
You can download DroidFS from [F-Droid](https://f-droid.org/packages/sushi.hardcore.droidfs) or from the "Releases" section in the repo.
APKs available here are signed with my PGP key available on keyservers:
@ -94,16 +65,25 @@ __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 needs some permissions for certain features. However, you are free to deny them if you do not wish to use these features.
DroidFS need some permissions to work properly. Here is why:
- **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.
<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>
# Limitations
DroidFS works as a wrapper around modified versions of the original encrypted container implementations ([libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) and [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs)). These programs were designed to run on standard x86 Linux systems: they access the underlying file system with file paths and syscalls. However, on Android, you can't access files from other applications using file paths. Instead, one has to use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. Obviously, neither Gocryptfs nor CryFS support this API. As a result, DroidFS cannot open volumes provided by other applications (such as cloud storage clients). If you want to synchronize your volumes on a cloud, the cloud application must synchronize the encrypted directory from disk.
DroidFS works as a wrapper around modified versions of the original encrypted container implementations ([libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) and [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs)). These programs were designed to run on standard x86 Linux systems: they access the underlying file system with file paths and syscalls. However, on Android, you can't access files from other applications using file paths. Instead, one has to use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. Obviously, neither Gocryptfs nor CryFS support this API. As a result, DroidFS cannot open volumes provided by other applications (such as cloud storage clients), nor can it allow other applications to access encrypted volumes once opened.
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.
@ -116,11 +96,11 @@ Thanks to these open source projects that DroidFS uses:
### Modified code:
- 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))
- [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
- [MaterialFiles](https://github.com/zhanghai/MaterialFiles) for kotlin natural sorting implementation
### Libraries:
- [Glide](https://github.com/bumptech/glide) to display pictures
- [Glide](https://github.com/bumptech/glide/) to display pictures
- [ExoPlayer](https://github.com/google/ExoPlayer) to play media files

30
TODO.md
View File

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

View File

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

View File

@ -14,39 +14,24 @@ if (hasProperty("disableGocryptfs")) {
}
android {
compileSdk 34
ndkVersion '25.2.9519653'
namespace "sushi.hardcore.droidfs"
compileSdkVersion 31
buildToolsVersion "31"
ndkVersion "23.1.7779620"
compileOptions {
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "17"
}
def abiCodes = [ "x86": 1, "x86_64": 2, "armeabi-v7a": 3, "arm64-v8a": 4]
defaultConfig {
applicationId "sushi.hardcore.droidfs"
minSdkVersion 21
targetSdkVersion 34
versionCode 37
versionName "2.2.0"
//noinspection ExpiredTargetSdkVersion
targetSdkVersion 29
versionCode 27
versionName "1.10.1"
splits {
abi {
enable true
reset() // fix unknown bug (https://ru.stackoverflow.com/questions/1557805/abis-armeabi-mips-mips64-riscv64-are-not-supported-for-platform)
if (project.hasProperty("abi")) {
include project.getProperty("abi")
} else {
abiCodes.keySet().each { abi -> include abi }
universalApk !project.hasProperty("nouniversal")
}
}
ndk {
abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
}
externalNativeBuild.cmake {
@ -54,41 +39,35 @@ android {
arguments "-DGOCRYPTFS=OFF"
}
if (project.ext.disableCryFS) {
arguments "-DCRYFS=OFF"
arguments "-DCRYFS=OFF"
}
}
}
applicationVariants.configureEach { variant ->
if (!file("fdroid").exists()) {
splits {
abi {
enable true
universalApk true
}
}
}
applicationVariants.all { 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 {
postprocessing {
removeUnusedCode true
removeUnusedResources true
obfuscate false
optimizeCode true
proguardFiles 'proguard-rules.pro'
}
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
@ -97,45 +76,32 @@ android {
path file('CMakeLists.txt')
}
}
sourceSets {
main {
java {
exclude 'androidx/camera/video/originals/**'
}
}
}
}
dependencies {
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 fileTree(dir: "libs", include: ["*.jar"])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'androidx.core:core-ktx:1.7.0'
implementation "androidx.appcompat:appcompat:1.4.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.3"
implementation "androidx.preference:preference-ktx:1.2.1"
implementation "androidx.sqlite:sqlite-ktx:2.2.0"
implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.swiperefreshlayout:swiperefreshlayout: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"
implementation 'com.google.android.material:material:1.5.0'
implementation "com.github.bumptech.glide:glide:4.12.0"
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha04"
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"
def exoplayer_version = "2.17.1"
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
def camerax_version = "1.3.4"
implementation "androidx.concurrent:concurrent-futures:1.1.0"
def camerax_version = "1.1.0-beta03"
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"
// 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"
}

View File

@ -1 +1,2 @@
ffmpeg
build

View File

@ -1,13 +1,13 @@
#!/bin/bash
set -e
if [ -z ${ANDROID_NDK_HOME+x} ]; then
echo "Error: \$ANDROID_NDK_HOME is not defined." >&2
exit 1
elif [ $# -lt 1 ]; then
echo "Usage: $0 <FFmpeg source directory> [<ABI>]" >&2
exit 1
else
cd "$(dirname "$0")"
FFMPEG_DIR="ffmpeg"
FFMPEG_DIR=$1
compile_for_arch() {
echo "Compiling for $1..."
case $1 in
@ -29,8 +29,7 @@ else
ARCH="arm"
;;
esac
(cd $FFMPEG_DIR
make clean || true
(cd $FFMPEG_DIR && make clean;
./configure \
--cc="$CFN" \
--cxx="$CFN++" \
@ -64,7 +63,6 @@ else
--disable-sndio \
--disable-schannel \
--disable-securetransport \
--disable-vulkan \
--disable-xlib \
--disable-zlib \
--disable-cuvid \
@ -74,19 +72,22 @@ else
--disable-audiotoolbox \
--disable-appkit \
--disable-alsa \
--disable-debug
make -j "$(nproc --all)" >/dev/null)
mkdir -p "build/$1/libavformat" "build/$1/libavcodec" "build/$1/libavutil"
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so "build/$1/libavformat"
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so "build/$1/libavcodec"
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so "build/$1/libavutil"
--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 ||
exit 1
}
export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
if [ $# -eq 1 ]; then
compile_for_arch "$1"
if [ $# -eq 2 ]; then
compile_for_arch $2
else
for abi in "x86_64" "x86" "arm64-v8a" "armeabi-v7a"; do
declare -a ABIs=("x86_64" "x86" "arm64-v8a" "armeabi-v7a")
for abi in ${ABIs[@]}; do
compile_for_arch $abi
done
fi

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

@ -1 +1 @@
Subproject commit cd0af7088066f870f12eceed9836bde897f1d164
Subproject commit 335815d25ac90f3d4964f88b542746ab5b270397

@ -1 +1 @@
Subproject commit b221d4cf3c70edc711169a769a476802d2577b2b
Subproject commit 7afeb9f3a4e0316365e843d3f4afd50201fbb253

View File

@ -1,18 +1,31 @@
-keepattributes SourceFile,LineNumberTable
# 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
-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.FFmpegMuxer {
-keepclassmembers class sushi.hardcore.droidfs.video_recording.MediaMuxer {
void writePacket(byte[]);
void seek(long);
}
# Required for Intent.getParcelableExtra() to work on Android 13
-keep class sushi.hardcore.droidfs.VolumeData {
public int describeContents();
}
-keep class sushi.hardcore.droidfs.VolumeData$* {
static public android.os.Parcelable$Creator CREATOR;
}
}

View File

@ -1,12 +1,13 @@
<?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">
<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" />
<permission
android:name="${applicationId}.WRITE_TEMPORARY_STORAGE"
android:protectionLevel="signature" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" />
@ -24,14 +25,12 @@
tools:node="remove" /> <!--removing this permission automatically added by exoplayer-->
<application
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:icon="@mipmap/icon_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/BaseTheme"
android:name=".VolumeManagerApp"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/backup_rules">
android:theme="@style/BaseTheme">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -50,40 +49,26 @@
<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.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.PdfViewer" android:configChanges="screenSize|orientation" android:theme="@style/AppTheme" />
<activity android:name=".file_viewers.PdfViewer" android:configChanges="screenSize|orientation" />
<activity android:name=".file_viewers.AudioPlayer" android:configChanges="screenSize|orientation" />
<activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" />
<activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
<activity android:name=".LogcatActivity"/>
<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=".NotificationBroadcastReceiver" android:exported="false">
<receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false">
<intent-filter>
<action android:name="file_operation_cancel"/>
</intent-filter>
</receiver>
<provider
android:name=".content_providers.TemporaryFileProvider"
android:name=".content_providers.RestrictedFileProvider"
android:authorities="${applicationId}.temporary_provider"
android:exported="true"/>
<provider
android:authorities="${applicationId}.volume_provider"
android:name=".content_providers.VolumeProvider"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
android:writePermission="${applicationId}.WRITE_TEMPORARY_STORAGE" />
</application>
</manifest>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,263 +0,0 @@
/*
* 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);
}
}

View File

@ -1,20 +0,0 @@
# 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
```

View File

@ -1,252 +0,0 @@
/*
* 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);
}
}

View File

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

View File

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

View File

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

View File

@ -8,18 +8,46 @@ import androidx.preference.PreferenceManager
open class BaseActivity: AppCompatActivity() {
protected lateinit var sharedPrefs: SharedPreferences
protected var applyCustomTheme: Boolean = true
lateinit var theme: Theme
lateinit var themeValue: String
private var shouldCheckTheme = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
theme = Theme.fromSharedPrefs(sharedPrefs)
if (applyCustomTheme) {
setTheme(theme.toResourceId())
if (shouldCheckTheme) {
themeValue = sharedPrefs.getString("theme", ConstValues.DEFAULT_THEME_VALUE)!!
when (themeValue) {
"black_green" -> setTheme(R.style.BlackGreen)
"dark_red" -> setTheme(R.style.DarkRed)
"black_red" -> setTheme(R.style.BlackRed)
"dark_blue" -> setTheme(R.style.DarkBlue)
"black_blue" -> setTheme(R.style.BlackBlue)
"dark_yellow" -> setTheme(R.style.DarkYellow)
"black_yellow" -> setTheme(R.style.BlackYellow)
"dark_orange" -> setTheme(R.style.DarkOrange)
"black_orange" -> setTheme(R.style.BlackOrange)
"dark_purple" -> setTheme(R.style.DarkPurple)
"black_purple" -> setTheme(R.style.BlackPurple)
}
} else {
shouldCheckTheme = true
}
if (!sharedPrefs.getBoolean("usf_screenshot", false)) {
super.onCreate(savedInstanceState)
if (!sharedPrefs.getBoolean("usf_screenshot", false)){
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
}
}
override fun onStart() {
super.onStart()
val newThemeValue = sharedPrefs.getString("theme", "dark_green")!!
onThemeChanged(newThemeValue)
}
fun onThemeChanged(newThemeValue: String) {
if (newThemeValue != themeValue) {
themeValue = newThemeValue
shouldCheckTheme = false
recreate()
}
}
}

View File

@ -2,15 +2,15 @@ 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.*
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
@ -18,51 +18,30 @@ import android.widget.ImageView
import android.widget.RelativeLayout
import android.widget.Toast
import androidx.annotation.RequiresApi
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.camera2.interop.Camera2CameraInfo
import androidx.camera.core.*
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 androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
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.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.Random
import java.util.*
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
@ -82,12 +61,14 @@ 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 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
@ -95,17 +76,11 @@ 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<SucklessRecorder>? = null
private var videoRecorder: SucklessRecorder? = null
private var videoRecording: SucklessRecording? = null
private var videoCapture: VideoCapture? = 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
@ -115,19 +90,18 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false)
binding = ActivityCameraBinding.inflate(layoutInflater)
setContentView(binding.root)
supportActionBar?.hide()
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
intent.getIntExtra("volumeId", -1)
)!!
finishOnClose(encryptedVolume)
encryptedVolume = intent.getParcelableExtra("volume")!!
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 {
@ -149,74 +123,48 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
}
binding.imageCaptureMode.setOnClickListener {
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()
}
val currentIndex = if (captureMode == ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) {
0
} 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()
}
.setNegativeButton(R.string.cancel, null)
.show()
1
}
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.camera_optimization)
.setSingleChoiceItems(arrayOf(getString(R.string.maximize_quality), getString(R.string.minimize_latency)), 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)
if (!isInVideoMode) {
cameraProvider.unbind(imageCapture)
refreshImageCapture()
cameraProvider.bindToLifecycle(this, cameraSelector, imageCapture)
}
}
dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
binding.imageRatio.setOnClickListener {
if (isInVideoMode) {
CustomAlertDialogBuilder(this, theme)
.setTitle("Aspect ratio:")
.setSingleChoiceItems(arrayOf("16:9", "4:3"), currentAspectRatioIndex) { dialog, which ->
currentAspectRatioIndex = which
rebindUseCases()
resolutions?.let {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.choose_resolution)
.setSingleChoiceItems(it.map { size -> size.toString() }.toTypedArray(), currentResolutionIndex) { dialog, which ->
currentResolution = resolutions!![which]
currentResolutionIndex = which
setupCamera()
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()
}
}
}
binding.imageTimer.setOnClickListener {
@ -264,19 +212,19 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
}
binding.imageModeSwitch.setOnClickListener {
isInVideoMode = !isInVideoMode
rebindUseCases()
setupCamera()
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 {
@ -301,7 +249,6 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
true
}
resolutions = null
qualities = null
setupCamera()
}
binding.takePhotoButton.onClick = ::onClickTakePhoto
@ -336,17 +283,21 @@ 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 {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.camera_perm_needed)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> finish() }.show()
.setPositiveButton(R.string.ok) { _, _ ->
isFinishingIntentionally = true
finish()
}.show()
}
AUDIO_PERMISSION_REQUEST_CODE -> if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
if (videoCapture != null) {
@ -358,18 +309,6 @@ 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
val screenHeight = resources.displayMetrics.heightPixels
@ -389,59 +328,52 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
imageCapture = ImageCapture.Builder()
.setCaptureMode(captureMode)
.setFlashMode(imageCapture?.flashMode ?: ImageCapture.FLASH_MODE_AUTO)
.setResolutionSelector(ResolutionSelector.Builder().setResolutionFilter { supportedSizes, _ ->
resolutions = supportedSizes.sortedBy {
-it.width*it.height
.apply {
currentResolution?.let {
setTargetResolution(it)
}
currentResolution?.let { targetResolution ->
return@setResolutionFilter supportedSizes.sortedBy {
sqrt((it.width - targetResolution.width).toDouble().pow(2) + (it.height - targetResolution.height).toDouble().pow(2))
}
}
supportedSizes
}.build())
.setTargetRotation(currentRotation)
}
.build()
}
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 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)
videoCapture = VideoCapture.Builder().apply {
currentResolution?.let {
setTargetResolution(it)
}
videoCapture
} else {
refreshImageCapture()
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture)
imageCapture
})!!
adaptPreviewSize(currentUseCase.attachedSurfaceResolution!!.swap())
return currentUseCase
}.build()
}
@SuppressLint("RestrictedApi")
private fun setupCamera() {
if (permissionsGranted && ::extensionsManager.isInitialized && ::cameraProvider.isInitialized) {
cameraSelector = if (isBackCamera){ CameraSelector.DEFAULT_BACK_CAMERA } else { CameraSelector.DEFAULT_FRONT_CAMERA }
if (extensionsManager.isExtensionAvailable(cameraSelector, ExtensionMode.AUTO)) {
cameraSelector = extensionsManager.getExtensionEnabledCameraSelector(cameraSelector, ExtensionMode.AUTO)
}
rebindUseCases()
cameraProvider.unbindAll()
val currentUseCase = (if (isInVideoMode) {
refreshVideoCapture()
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture)
videoCapture
} else {
refreshImageCapture()
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture)
imageCapture
})!!
adaptPreviewSize(currentResolution ?: currentUseCase.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(currentUseCase.imageFormat).map { it.swap() }
}
}
}
}
@ -488,10 +420,14 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
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)
CustomAlertDialogBuilder(this@CameraActivity, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.picture_save_failed)
.setPositiveButton(R.string.ok, null)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ ->
isFinishingIntentionally = true
finish()
}
.show()
}
}
@ -508,79 +444,61 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
@SuppressLint("MissingPermission")
private fun onClickRecordVideo() {
if (isRecording) {
videoRecording?.stop()
videoCapture?.stopRecording()
isRecording = false
} else if (!isWaitingForTimer) {
val path = getOutputPath(true)
val fileHandle = encryptedVolume.openFileWriteMode(path)
if (fileHandle == -1L) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.file_creation_failed)
.setPositiveButton(R.string.ok, null)
.show()
return
}
val writer = AsynchronousSeekableWriter(object : SeekableWriter {
private var offset = 0L
override fun close() {
encryptedVolume.closeFile(fileHandle)
}
override fun seek(offset: Long) {
this.offset = offset
}
override fun write(buffer: ByteArray, size: Int) {
offset += encryptedVolume.write(fileHandle, offset, buffer, 0, size.toLong())
}
})
val pendingRecording = videoRecorder!!.prepareRecording(
this,
MuxerOutputOptions(FFmpegMuxer(writer))
).also {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
it.withAudioEnabled()
}
}
startTimerThen {
writer.start()
videoRecording = pendingRecording.start(executor) {
val buttons = arrayOf(binding.imageCaptureMode, binding.imageRatio, binding.imageTimer, binding.imageModeSwitch, binding.imageCameraSwitch)
when (it) {
is VideoRecordEvent.Start -> {
binding.recordVideoButton.setImageResource(R.drawable.stop_recording_video_button)
for (i in buttons) {
i.isEnabled = false
i.alpha = 0.5F
}
isRecording = true
}
is VideoRecordEvent.Finalize -> {
if (it.hasError()) {
it.cause?.printStackTrace()
Toast.makeText(applicationContext, it.cause?.message ?: ("Error: " + it.error), Toast.LENGTH_SHORT).show()
videoRecording?.close()
videoRecording = null
} else {
Toast.makeText(applicationContext, getString(R.string.video_save_success, path), Toast.LENGTH_SHORT).show()
}
binding.recordVideoButton.setImageResource(R.drawable.record_video_button)
for (i in buttons) {
i.isEnabled = true
i.alpha = 1F
}
isRecording = false
}
val fileHandle = encryptedVolume.openFile(path)
videoCapture?.startRecording(VideoCapture.OutputFileOptions(object : SeekableWriter {
var offset = 0L
override fun write(byteArray: ByteArray) {
offset += encryptedVolume.write(fileHandle, offset, byteArray, byteArray.size)
}
}
override fun seek(offset: Long) {
this.offset = offset
}
override fun close() {
encryptedVolume.closeFile(fileHandle)
}
}), 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)
}
})
binding.recordVideoButton.setImageResource(R.drawable.stop_recording_video_button)
isRecording = true
}
}
}
override fun onDestroy() {
super.onDestroy()
if (!isFinishingIntentionally) {
encryptedVolume.close()
RestrictedFileProvider.wipeAll(this)
}
}
override fun onStop() {
super.onStop()
if (!isFinishing && !usf_keep_open){
finish()
}
}
override fun onPause() {
super.onPause()
sensorOrientationListener.remove(this)
if (!isAskingPermissions && !usf_keep_open) {
finish()
}
}
override fun onResume() {
@ -588,6 +506,11 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
sensorOrientationListener.addListener(this)
}
override fun onBackPressed() {
super.onBackPressed()
isFinishingIntentionally = true
}
override fun onOrientationChange(newOrientation: Int) {
val realOrientation = when (newOrientation) {
Surface.ROTATION_0 -> 0f
@ -606,8 +529,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
orientedIcons.map { it.startAnimation(rotateAnimation) }
previousOrientation = realOrientation
imageCapture?.targetRotation = newOrientation
videoCapture?.targetRotation = newOrientation
currentRotation = newOrientation
videoCapture?.setTargetRotation(newOrientation)
}
}

View File

@ -1,39 +1,29 @@
package sushi.hardcore.droidfs
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.text.InputType
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import sushi.hardcore.droidfs.databinding.ActivityChangePasswordBinding
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: BaseActivity() {
private lateinit var binding: ActivityChangePasswordBinding
private lateinit var volume: VolumeData
private lateinit var volume: SavedVolume
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")!!
volume = intent.getParcelableExtra("volume")!!
binding = ActivityChangePasswordBinding.inflate(layoutInflater)
setContentView(binding.root)
title = getString(R.string.change_password)
@ -42,33 +32,20 @@ class ChangePasswordActivity: BaseActivity() {
volumeDatabase = VolumeDatabase(this)
usfFingerprint = sharedPrefs.getBoolean("usf_fingerprint", false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
fingerprintProtector = FingerprintProtector.new(this, theme, volumeDatabase)
fingerprintProtector = FingerprintProtector.new(this, themeValue, volumeDatabase)
if (fingerprintProtector != null && volume.encryptedHash != null) {
binding.fingerprintSwitchContainer.visibility = View.VISIBLE
binding.textCurrentPasswordLabel.visibility = View.GONE
binding.editCurrentPassword.visibility = View.GONE
}
}
if (!usfFingerprint || fingerprintProtector == null) {
binding.checkboxSavePassword.visibility = View.GONE
}
if (sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false)) {
if (sharedPrefs.getBoolean(ConstValues.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
@ -89,35 +66,35 @@ class ChangePasswordActivity: BaseActivity() {
}
private fun changeVolumePassword() {
val newPassword = UIUtils.encodeEditTextContent(binding.editNewPassword)
val newPasswordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
val newPassword = CharArray(binding.editNewPassword.text.length)
binding.editNewPassword.text.getChars(0, newPassword.size, newPassword, 0)
val newPasswordConfirm = CharArray(binding.editPasswordConfirm.text.length)
binding.editPasswordConfirm.text.getChars(0, newPasswordConfirm.size, newPasswordConfirm, 0)
@SuppressLint("NewApi")
if (!newPassword.contentEquals(newPasswordConfirm)) {
Toast.makeText(this, R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
Arrays.fill(newPassword, 0)
Arrays.fill(newPassword, 0.toChar())
} else {
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)
}
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.toChar())
}
it.loadPasswordHash(volume.name, encryptedHash, iv)
}
it.loadPasswordHash(volume.name, encryptedHash, iv)
}
}
}
@ -125,48 +102,30 @@ class ChangePasswordActivity: BaseActivity() {
changeVolumePassword(newPassword)
}
}
Arrays.fill(newPasswordConfirm, 0)
Arrays.fill(newPasswordConfirm, 0.toChar())
}
private fun changeVolumePassword(newPassword: ByteArray, givenHash: ByteArray? = null) {
val returnedHash: ObjRef<ByteArray?>? = if (binding.checkboxSavePassword.isChecked) {
ObjRef(null)
} else {
null
private fun changeVolumePassword(newPassword: CharArray, givenHash: ByteArray? = null) {
var returnedHash: ByteArray? = null
if (binding.checkboxSavePassword.isChecked) {
returnedHash = ByteArray(GocryptfsVolume.KeyLen)
}
val currentPassword = if (givenHash == null) {
UIUtils.encodeEditTextContent(binding.editCurrentPassword)
} else {
null
var currentPassword: CharArray? = null
if (givenHash == null) {
currentPassword = CharArray(binding.editCurrentPassword.text.length)
binding.editCurrentPassword.text.getChars(0, currentPassword.size, currentPassword, 0)
}
object : LoadingTask<Boolean>(this, theme, R.string.loading_msg_change_password) {
object : LoadingTask<Boolean>(this, themeValue, 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
)
}
val success = GocryptfsVolume.changePassword(volume.getFullPath(filesDir.path), currentPassword, givenHash, newPassword, returnedHash)
if (success) {
if (volumeDatabase.isHashSaved(volume)) {
if (volumeDatabase.isHashSaved(volume.name)) {
volumeDatabase.removeHash(volume)
}
}
if (currentPassword != null)
Arrays.fill(currentPassword, 0)
Arrays.fill(newPassword, 0)
Arrays.fill(currentPassword, 0.toChar())
Arrays.fill(newPassword, 0.toChar())
if (givenHash != null)
Arrays.fill(givenHash, 0)
return success
@ -179,27 +138,27 @@ class ChangePasswordActivity: BaseActivity() {
it.listener = object : FingerprintProtector.Listener {
override fun onHashStorageReset() {
// retry
it.savePasswordHash(volume, returnedHash.value!!)
it.savePasswordHash(volume, returnedHash)
}
override fun onPasswordHashDecrypted(hash: ByteArray) {}
override fun onPasswordHashSaved() {
Arrays.fill(returnedHash.value!!, 0)
Arrays.fill(returnedHash, 0)
finish()
}
override fun onFailed(pending: Boolean) {
if (!pending) {
Arrays.fill(returnedHash.value!!, 0)
Arrays.fill(returnedHash, 0)
finish()
}
}
}
it.savePasswordHash(volume, returnedHash.value!!)
it.savePasswordHash(volume, returnedHash)
}
} else {
finish()
}
} else {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.change_password_failed)
.setPositiveButton(R.string.ok, null)
@ -207,11 +166,4 @@ class ChangePasswordActivity: BaseActivity() {
}
}
}
override fun onStop() {
super.onStop()
binding.editCurrentPassword.text.clear()
binding.editNewPassword.text.clear()
binding.editPasswordConfirm.text.clear()
}
}

View File

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

View File

@ -0,0 +1,47 @@
package sushi.hardcore.droidfs
import android.net.Uri
import java.io.File
object ConstValues {
const val CREATOR = "DroidFS"
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 MAX_KERNEL_WRITE = 128*1024
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 THUMBNAIL_MAX_SIZE_KEY = "thumbnail_max_size"
const val DEFAULT_THUMBNAIL_MAX_SIZE = 10_000L
const val PIN_PASSWORDS_KEY = "pin_passwords"
private val FILE_EXTENSIONS = mapOf(
Pair("image", listOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "heic")),
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac")),
Pair("pdf", listOf("pdf")),
Pair("text", listOf("txt", "json", "conf", "log", "xml", "java", "kt", "py", "pl", "rb", "go", "c", "h", "cpp", "hpp", "rs", "sh", "bat", "js", "html", "css", "php", "yml", "yaml", "toml", "ini", "md", "properties"))
)
fun isExtensionType(extensionType: String, path: String): Boolean {
return FILE_EXTENSIONS[extensionType]?.contains(File(path).extension.lowercase()) ?: false
}
fun isImage(path: String): Boolean {
return isExtensionType("image", path)
}
fun isVideo(path: String): Boolean {
return isExtensionType("video", path)
}
fun isAudio(path: String): Boolean {
return isExtensionType("audio", path)
}
fun isPDF(path: String): Boolean {
return isExtensionType("pdf", path)
}
fun isText(path: String): Boolean {
return isExtensionType("text", path)
}
}

View File

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

View File

@ -1,256 +0,0 @@
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.Dispatchers
import kotlinx.coroutines.GlobalScope
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() {
GlobalScope.launch(Dispatchers.IO) {
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 pfd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false)
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(pfd.fileDescriptor)).also {
pfd.close()
}
}
enum class Error {
SUCCESS,
INVALID_STATE,
WRITE_ACCESS_DENIED,
UNSUPPORTED_APPEND,
UNSUPPORTED_RW,
;
fun log() {
Log.e(
TAG, when (this) {
SUCCESS -> "No error"
INVALID_STATE -> "Read after write is not supported"
WRITE_ACCESS_DENIED -> "Write access unauthorized"
UNSUPPORTED_APPEND -> "Appending is not supported"
UNSUPPORTED_RW -> "Read-write access requires Android 11 or later"
}
)
}
}
/**
* @param furtive If set to true, the file will be deleted when closed
*/
fun openFile(
file: ExportedFile,
mode: String,
encryptedVolume: EncryptedVolume,
volumeScope: CoroutineScope,
furtive: Boolean,
allowWrites: Boolean,
): Pair<ParcelFileDescriptor?, Error> {
val mode = ParcelFileDescriptor.parseMode(mode)
return if (mode and ParcelFileDescriptor.MODE_READ_ONLY != 0) {
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
Pair(file.open(mode, furtive), Error.SUCCESS)
} else {
if (!allowWrites) {
return Pair(null, Error.WRITE_ACCESS_DENIED)
}
fun import(input: InputStream): Boolean {
return if (encryptedVolume.importFile(input, file.path)) {
true
} else {
Log.e(TAG, "Failed to import file")
false
}
}
if (mode and ParcelFileDescriptor.MODE_WRITE_ONLY != 0) {
if (mode and ParcelFileDescriptor.MODE_APPEND != 0) {
return Pair(null, Error.UNSUPPORTED_APPEND)
}
if (mode and ParcelFileDescriptor.MODE_TRUNCATE == 0) {
Log.w(TAG, "Truncating file despite not being requested")
}
val pipe = ParcelFileDescriptor.createReliablePipe()
val input = FileInputStream(pipe[0].fileDescriptor)
volumeScope.launch {
if (import(input)) {
file.invalidate()
}
}
Pair(pipe[1], Error.SUCCESS)
} else { // read-write
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val fd = file.open(mode, false)
Pair(ParcelFileDescriptor.wrap(fd, handler) { e ->
if (e == null) {
import(FileInputStream(fd.fileDescriptor))
if (furtive) {
file.free()
}
}
}, Error.SUCCESS)
} else {
Pair(null, Error.UNSUPPORTED_RW)
}
}
}
}
}

View File

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

View File

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

View File

@ -22,7 +22,7 @@ import javax.crypto.spec.GCMParameterSpec
@RequiresApi(Build.VERSION_CODES.M)
class FingerprintProtector private constructor(
private val activity: FragmentActivity,
private val theme: Theme,
private val themeValue: String,
private val volumeDatabase: VolumeDatabase,
) {
@ -54,11 +54,11 @@ class FingerprintProtector private constructor(
fun new(
activity: FragmentActivity,
theme: Theme,
themeValue: String,
volumeDatabase: VolumeDatabase,
): FingerprintProtector? {
return if (canAuthenticate(activity) == 0)
FingerprintProtector(activity, theme, volumeDatabase)
FingerprintProtector(activity, themeValue, volumeDatabase)
else
null
}
@ -98,7 +98,7 @@ class FingerprintProtector private constructor(
listener.onPasswordHashDecrypted(plainText)
} catch (e: AEADBadTagException) {
listener.onFailed(true)
CustomAlertDialogBuilder(activity, theme)
CustomAlertDialogBuilder(activity, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.MAC_verification_failed)
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
@ -112,7 +112,7 @@ class FingerprintProtector private constructor(
}
} catch (e: IllegalBlockSizeException) {
listener.onFailed(true)
CustomAlertDialogBuilder(activity, theme)
CustomAlertDialogBuilder(activity, themeValue)
.setTitle(R.string.illegal_block_size_exception)
.setMessage(R.string.illegal_block_size_exception_msg)
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
@ -133,7 +133,7 @@ class FingerprintProtector private constructor(
private lateinit var cipher: Cipher
private var isCipherReady = false
private var cipherActionMode: Int? = null
private lateinit var volume: VolumeData
private lateinit var volume: SavedVolume
private lateinit var dataToProcess: ByteArray
private fun resetHashStorage() {
@ -159,7 +159,7 @@ class FingerprintProtector private constructor(
keyStore.getKey(KEY_ALIAS, null) as SecretKey
} catch (e: UnrecoverableKeyException) {
listener.onFailed(true)
CustomAlertDialogBuilder(activity, theme)
CustomAlertDialogBuilder(activity, themeValue)
.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) { _, _ ->
@ -196,7 +196,7 @@ class FingerprintProtector private constructor(
private fun alertKeyPermanentlyInvalidatedException() {
listener.onFailed(true)
CustomAlertDialogBuilder(activity, theme)
CustomAlertDialogBuilder(activity, themeValue)
.setTitle(R.string.key_permanently_invalidated_exception)
.setMessage(R.string.key_permanently_invalidated_exception_msg)
.setPositiveButton(R.string.reset_hash_storage) { _, _ ->
@ -207,7 +207,7 @@ class FingerprintProtector private constructor(
.show()
}
fun savePasswordHash(volume: VolumeData, plainText: ByteArray) {
fun savePasswordHash(volume: SavedVolume, plainText: ByteArray) {
this.volume = volume
val biometricPromptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(activity.getString(R.string.encrypt_action_description))

View File

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

View File

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

View File

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

View File

@ -1,67 +1,85 @@
package sushi.hardcore.droidfs
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.text.InputType
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.WindowManager
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.content_providers.RestrictedFileProvider
import sushi.hardcore.droidfs.databinding.ActivityMainBinding
import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerRouter
import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerActivity
import sushi.hardcore.droidfs.explorers.ExplorerActivityDrop
import sushi.hardcore.droidfs.explorers.ExplorerActivityPick
import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.util.WidgetUtil
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
import java.io.File
import java.util.*
class MainActivity : BaseActivity(), VolumeAdapter.Listener {
companion object {
private const val OPEN_DEFAULT_VOLUME = "openDefault"
const val DEFAULT_VOLUME_KEY = "default_volume"
}
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 fingerprintProtector: FingerprintProtector? = null
private var usfFingerprint: Boolean = false
private var usfKeepOpen: Boolean = false
private var defaultVolumeName: String? = null
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()
when (result.resultCode) {
AddVolumeActivity.RESULT_VOLUME_ADDED -> onVolumeAdded()
AddVolumeActivity.RESULT_HASH_STORAGE_RESET -> {
volumeAdapter.refresh()
binding.textNoVolumes.visibility = View.GONE
}
}
}
private var selectedVolumePosition: Int? = null
private var changePasswordPosition: Int? = null
private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
selectedVolumePosition?.let { unselect(it) }
changePasswordPosition?.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
private var pickMode = false
private var dropMode = false
private var shouldCloseVolume = true // used when launched to pick file from another volume
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
if (sharedPrefs.getBoolean("applicationFirstOpening", true)) {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.warning)
.setMessage(R.string.usf_home_warning_msg)
.setCancelable(false)
@ -79,15 +97,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
.show()
}
explorerRouter = ExplorerRouter(this, intent)
volumeManager = (application as VolumeManagerApp).volumeManager
pickMode = intent.action == "pick"
dropMode = (intent.action == Intent.ACTION_SEND || intent.action == Intent.ACTION_SEND_MULTIPLE) && intent.extras != null
volumeDatabase = VolumeDatabase(this)
volumeAdapter = VolumeAdapter(
this,
volumeDatabase,
(application as VolumeManagerApp).volumeManager,
!explorerRouter.pickMode && !explorerRouter.dropMode,
!explorerRouter.dropMode,
!pickMode && !dropMode,
!dropMode,
this,
)
binding.recyclerViewVolumes.adapter = volumeAdapter
@ -95,63 +112,42 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
if (volumeAdapter.volumes.isEmpty()) {
binding.textNoVolumes.visibility = View.VISIBLE
}
if (explorerRouter.pickMode) {
if (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.fab.visibility = View.GONE
} else {
binding.fab.setOnClickListener {
addVolume.launch(Intent(this, AddVolumeActivity::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()
}
usfKeepOpen = sharedPrefs.getBoolean("usf_keep_open", false)
usfFingerprint = sharedPrefs.getBoolean("usf_fingerprint", false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
fingerprintProtector = FingerprintProtector.new(this, themeValue, volumeDatabase)
}
defaultVolumeName = sharedPrefs.getString(DEFAULT_VOLUME_KEY, null)
defaultVolumeName?.let { name ->
try {
val (position, volume) = volumeAdapter.volumes.withIndex().first { it.value.name == name }
openVolume(volume, position)
} catch (e: NoSuchElementException) {
unsetDefaultVolume()
}
}
FileOperationService.bind(this) {
fileOperationService = it
Intent(this, FileOperationService::class.java).also {
bindService(it, object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
fileOperationService = (service as FileOperationService.LocalBinder).getService()
}
override fun onServiceDisconnected(arg0: ComponentName) {}
}, Context.BIND_AUTO_CREATE)
}
}
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)
// refresh this in case another instance of MainActivity changes its value
defaultVolumeName = sharedPrefs.getString(DEFAULT_VOLUME_KEY, null)
}
override fun onSelectionChanged(size: Int) {
@ -162,9 +158,9 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
}
override fun onVolumeItemClick(volume: VolumeData, position: Int) {
override fun onVolumeItemClick(volume: SavedVolume, position: Int) {
if (volumeAdapter.selectedItems.isEmpty())
openVolume(volume)
openVolume(volume, position)
else
invalidateOptionsMenu()
}
@ -173,22 +169,27 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
invalidateOptionsMenu()
}
private fun onVolumeAdded() {
volumeAdapter.apply {
volumes = volumeDatabase.getVolumes()
notifyItemInserted(volumes.size)
}
binding.textNoVolumes.visibility = View.GONE
}
private fun unselectAll(notifyChange: Boolean = true) {
volumeAdapter.unSelectAll(notifyChange)
invalidateOptionsMenu()
}
private fun unselect(position: Int) {
volumeAdapter.unselect(position)
volumeAdapter.selectedItems.remove(position)
volumeAdapter.onVolumeChanged(position)
onSelectionChanged(0) // unselect() is always called when only one element is selected
invalidateOptionsMenu()
}
private fun removeVolume(volume: VolumeData) {
volumeManager.getVolumeId(volume)?.let { volumeManager.closeVolume(it) }
volumeDatabase.removeVolume(volume)
}
private fun removeVolumes(volumes: List<VolumeData>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
private fun removeVolumes(volumes: List<SavedVolume>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
if (i < volumes.size) {
if (volumes[i].isHidden) {
if (doDeleteVolumeContent == null) {
@ -201,16 +202,16 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
break
}
}
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.warning)
.setView(dialogBinding.root)
.setPositiveButton(R.string.forget_only) { _, _ ->
removeVolume(volumes[i])
volumeDatabase.removeVolume(volumes[i].name)
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])
volumeDatabase.removeVolume(volumes[i].name)
removeVolumes(volumes, i + 1, if (dialogBinding.checkboxApplyToAll.isChecked) true else null)
}
.setOnCancelListener {
@ -222,11 +223,11 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
if (doDeleteVolumeContent) {
PathUtils.recursiveRemoveDirectory(File(volumes[i].getFullPath(filesDir.path)))
}
removeVolume(volumes[i])
volumeDatabase.removeVolume(volumes[i].name)
removeVolumes(volumes, i + 1, doDeleteVolumeContent)
}
} else {
removeVolume(volumes[i])
volumeDatabase.removeVolume(volumes[i].name)
removeVolumes(volumes, i + 1, doDeleteVolumeContent)
}
} else {
@ -243,13 +244,15 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
remove(DEFAULT_VOLUME_KEY)
apply()
}
volumeOpener.defaultVolumeName = null
defaultVolumeName = null
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
if (explorerRouter.pickMode || explorerRouter.dropMode) {
if (pickMode || dropMode) {
if (pickMode)
shouldCloseVolume = false
finish()
} else {
unselectAll()
@ -261,32 +264,23 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
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 -> {
R.id.forget_password -> {
for (i in volumeAdapter.selectedItems) {
if (volumeDatabase.removeHash(volumeAdapter.volumes[i]))
volumeAdapter.onVolumeDataChanged(i)
volumeAdapter.onVolumeChanged(i)
}
unselectAll(false)
true
}
R.id.change_password -> {
selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
changePasswordPosition = volumeAdapter.selectedItems.elementAt(0)
changePassword.launch(Intent(this, ChangePasswordActivity::class.java).apply {
putExtra("volume", volumeAdapter.volumes[selectedVolumePosition!!])
putExtra("volume", volumeAdapter.volumes[changePasswordPosition!!])
})
true
}
@ -296,33 +290,26 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
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)
val position = volumeAdapter.selectedItems.elementAt(0)
val volume = volumeAdapter.volumes[position]
when {
volume.isHidden -> {
PathUtils.safePickDirectory(pickDirectory, this, themeValue)
}
File(filesDir, volume.shortName).exists() -> {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.hidden_volume_already_exists)
.setPositiveButton(R.string.ok, null)
.show()
} else {
unselect(selectedVolumePosition!!)
}
else -> {
unselect(position)
copyVolume(
DocumentFile.fromFile(File(volume.name)),
DocumentFile.fromFile(hiddenVolumeFile.parentFile!!),
DocumentFile.fromFile(filesDir),
) {
VolumeData(
VolumeData.newUuid(),
volume.shortName,
true,
volume.type,
volume.encryptedHash,
volume.iv
)
SavedVolume(volume.shortName, true, volume.type, volume.encryptedHash, volume.iv)
}
}
}
@ -344,28 +331,25 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
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)
}
menu.findItem(R.id.settings).isVisible = !pickMode && !dropMode
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 =
menu.findItem(R.id.forget_password).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.change_password).isVisible =
onlyOneAndWriteable &&
// Only gocryptfs volumes support password change
!BuildConfig.GOCRYPTFS_DISABLED &&
volumeAdapter.volumes[volumeAdapter.selectedItems.first()].type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE
menu.findItem(R.id.remove_default_open).isVisible =
onlyOneSelected &&
volumeAdapter.volumes[volumeAdapter.selectedItems.first()].name == volumeOpener.defaultVolumeName
volumeAdapter.volumes[volumeAdapter.selectedItems.first()].name == defaultVolumeName
with(menu.findItem(R.id.copy)) {
isVisible = onlyOneSelected
if (isVisible) {
@ -376,17 +360,18 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
)
}
}
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable && !volumeManager.isOpen(volumeAdapter.volumes[volumeAdapter.selectedItems.first()])
supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode)
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable
supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || pickMode || dropMode)
return true
}
private fun onDirectoryPicked(uri: Uri) {
val volume = volumeAdapter.volumes[selectedVolumePosition!!]
unselect(selectedVolumePosition!!)
val position = volumeAdapter.selectedItems.elementAt(0)
val volume = volumeAdapter.volumes[position]
unselect(position)
val dstDocumentFile = DocumentFile.fromTreeUri(this, uri)
if (dstDocumentFile == null) {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.path_error)
.setPositiveButton(R.string.ok, null)
@ -399,8 +384,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
dstRootDirectory.name?.let { name ->
val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this)
if (path == null) null
else VolumeData(
VolumeData.newUuid(),
else SavedVolume(
PathUtils.pathJoin(path, name),
false,
volume.type,
@ -412,44 +396,34 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
}
/**
* Copy a volume.
*
* @param srcDocumentFile [DocumentFile] of the volume to copy
* @param dstDocumentFile [DocumentFile] of the destination PARENT FOLDER
* @param getResultVolume A function that returns the [VolumeData] corresponding to the destination volume. Takes the [DocumentFile] of the newly created volume (not the parent folder).
*/
private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> VolumeData?) {
private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> SavedVolume?) {
lifecycleScope.launch {
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
when (result.taskResult.state) {
TaskResult.State.SUCCESS -> {
when {
result.taskResult.cancelled -> {
result.dstRootDirectory?.delete()
}
result.taskResult.failedItem == null -> {
result.dstRootDirectory?.let {
getResultVolume(it)?.let { volume ->
volumeDatabase.saveVolume(volume)
volumeAdapter.apply {
volumes = volumeDatabase.getVolumes()
notifyItemInserted(volumes.size)
}
binding.textNoVolumes.visibility = View.GONE
onVolumeAdded()
Toast.makeText(this@MainActivity, R.string.copy_success, Toast.LENGTH_SHORT).show()
}
}
}
TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this@MainActivity, theme)
else -> {
CustomAlertDialogBuilder(this@MainActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem!!.name))
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem.name))
.setPositiveButton(R.string.ok, null)
.show()
}
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
TaskResult.State.CANCELLED -> {}
}
}
}
private fun renameVolume(volume: VolumeData, position: Int) {
private fun renameVolume(volume: SavedVolume, 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
@ -467,16 +441,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
DocumentFile.fromFile(srcPath).renameTo(newName)
}
if (success) {
volumeDatabase.renameVolume(volume, newDBName)
VolumeProvider.notifyRootsChanged(this)
volumeAdapter.onVolumeDataChanged(position)
volumeDatabase.renameVolume(volume.name, newDBName)
unselect(position)
if (volume.name == volumeOpener.defaultVolumeName) {
if (volume.name == defaultVolumeName) {
with (sharedPrefs.edit()) {
putString(DEFAULT_VOLUME_KEY, newDBName)
apply()
}
volumeOpener.defaultVolumeName = newDBName
defaultVolumeName = newDBName
}
} else {
Toast.makeText(this, R.string.volume_rename_failed, Toast.LENGTH_SHORT).show()
@ -487,23 +459,203 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
}
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()
@SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23
private fun openVolume(volume: SavedVolume, position: Int) {
if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE && BuildConfig.GOCRYPTFS_DISABLED) {
Toast.makeText(this, R.string.gocryptfs_disabled, Toast.LENGTH_SHORT).show()
return
} else if (volume.type == EncryptedVolume.CRYFS_VOLUME_TYPE && BuildConfig.CRYFS_DISABLED) {
Toast.makeText(this, 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() {
volumeAdapter.refresh()
}
override fun onPasswordHashDecrypted(hash: ByteArray) {
object : LoadingTask<EncryptedVolume?>(this@MainActivity, themeValue, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume? {
val encryptedVolume = EncryptedVolume.init(volume, filesDir.path, null, hash, null)
Arrays.fill(hash, 0)
return encryptedVolume
}
}.startTask(lifecycleScope) { encryptedVolume ->
if (encryptedVolume == null) {
CustomAlertDialogBuilder(this@MainActivity, themeValue)
.setTitle(R.string.open_volume_failed)
.setMessage(R.string.open_failed_hash_msg)
.setPositiveButton(R.string.ok, null)
.show()
} else {
startExplorer(encryptedVolume, volume.shortName)
}
}
}
override fun onPasswordHashSaved() {}
override fun onFailed(pending: Boolean) {
if (!pending) {
askForPassword(volume, position)
}
}
}
fingerprintProtector.loadPasswordHash(volume.shortName, encryptedHash, iv)
}
}
})
}
if (askForPassword)
askForPassword(volume, position)
}
private fun onPasswordSubmitted(volume: SavedVolume, position: Int, dialogBinding: DialogOpenVolumeBinding) {
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()
}
}
// openVolumeWithPassword is responsible for wiping the password
openVolumeWithPassword(
volume,
position,
WidgetUtil.encodeEditTextContent(dialogBinding.editPassword),
dialogBinding.checkboxSavePassword.isChecked,
)
}
private fun askForPassword(volume: SavedVolume, position: Int, savePasswordHash: Boolean = false) {
val dialogBinding = DialogOpenVolumeBinding.inflate(layoutInflater)
if (!usfFingerprint || fingerprintProtector == null || volume.encryptedHash != null || volume.type == EncryptedVolume.CRYFS_VOLUME_TYPE) {
dialogBinding.checkboxSavePassword.visibility = View.GONE
} else {
dialogBinding.checkboxSavePassword.isChecked = savePasswordHash
}
dialogBinding.checkboxDefaultOpen.isChecked = defaultVolumeName == volume.name
val dialog = CustomAlertDialogBuilder(this, themeValue)
.setTitle(getString(R.string.open_dialog_title, volume.shortName))
.setView(dialogBinding.root)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.open) { _, _ ->
onPasswordSubmitted(volume, position, dialogBinding)
}
.create()
dialogBinding.editPassword.apply {
setOnEditorActionListener { _, _, _ ->
dialog.dismiss()
onPasswordSubmitted(volume, position, dialogBinding)
true
}
if (sharedPrefs.getBoolean(ConstValues.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: SavedVolume, position: Int, password: ByteArray, savePasswordHash: Boolean) {
val usfFingerprint = sharedPrefs.getBoolean("usf_fingerprint", false)
var returnedHash: ByteArray? = null
if (savePasswordHash && usfFingerprint) {
returnedHash = ByteArray(GocryptfsVolume.KeyLen)
}
object : LoadingTask<EncryptedVolume?>(this, themeValue, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume? {
val encryptedVolume = EncryptedVolume.init(volume, filesDir.path, password, null, returnedHash)
Arrays.fill(password, 0)
return encryptedVolume
}
}.startTask(lifecycleScope) { encryptedVolume ->
if (encryptedVolume == null) {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.open_volume_failed)
.setMessage(R.string.open_volume_failed_msg)
.setPositiveButton(R.string.ok) { _, _ ->
askForPassword(volume, position, 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() {
volumeAdapter.refresh()
}
override fun onPasswordHashDecrypted(hash: ByteArray) {}
override fun onPasswordHashSaved() {
Arrays.fill(returnedHash, 0)
volumeAdapter.onVolumeChanged(position)
startExplorer(encryptedVolume, volume.shortName)
}
private var isClosed = false
override fun onFailed(pending: Boolean) {
if (!isClosed) {
encryptedVolume.close()
isClosed = true
}
Arrays.fill(returnedHash, 0)
}
}
fingerprintProtector.savePasswordHash(volume, returnedHash)
} else {
startExplorer(encryptedVolume, volume.shortName)
}
}
}
}
private fun startExplorer(encryptedVolume: EncryptedVolume, volumeShortName: String) {
var explorerIntent: Intent? = null
if (dropMode) { //import via android share menu
explorerIntent = Intent(this, ExplorerActivityDrop::class.java)
explorerIntent.action = intent.action //forward action
explorerIntent.putExtras(intent.extras!!) //forward extras
} else if (pickMode) {
explorerIntent = Intent(this, ExplorerActivityPick::class.java)
explorerIntent.putExtra("destinationVolume", intent.getParcelableExtra<EncryptedVolume>("volume")!!)
explorerIntent.flags = Intent.FLAG_ACTIVITY_FORWARD_RESULT
}
if (explorerIntent == null) {
explorerIntent = Intent(this, ExplorerActivity::class.java) //default opening
}
explorerIntent.putExtra("volume", encryptedVolume)
explorerIntent.putExtra("volume_name", volumeShortName)
startActivity(explorerIntent)
if (pickMode)
shouldCloseVolume = false
if (dropMode || pickMode)
finish()
}
override fun onBackPressed() {
if (volumeAdapter.selectedItems.isNotEmpty()) {
unselectAll()
} else {
if (pickMode)
shouldCloseVolume = false
super.onBackPressed()
}
}
override fun onStop() {
super.onStop()
volumeOpener.wipeSensitive()
if (pickMode && !usfKeepOpen) {
finish()
if (shouldCloseVolume) {
intent.getParcelableExtra<EncryptedVolume>("volume")?.close()
RestrictedFileProvider.wipeAll(this)
}
}
}
}

View File

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

View File

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

View File

@ -0,0 +1,60 @@
package sushi.hardcore.droidfs
import android.os.Parcel
import android.os.Parcelable
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
class SavedVolume(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.readByte() != 0.toByte(),
parcel.readByte(),
parcel.createByteArray(),
parcel.createByteArray()
)
val shortName: String by lazy {
File(name).name
}
fun getFullPath(filesDir: String): String {
return if (isHidden)
getHiddenVolumeFullPath(filesDir, name)
else
name
}
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(name)
writeByte(if (isHidden) 1 else 0)
writeByte(type)
writeByteArray(encryptedHash)
writeByteArray(iv)
}
}
companion object {
const val VOLUMES_DIRECTORY = "volumes"
@JvmField
val CREATOR = object : Parcelable.Creator<SavedVolume> {
override fun createFromParcel(parcel: Parcel) = SavedVolume(parcel)
override fun newArray(size: Int) = arrayOfNulls<SavedVolume>(size)
}
fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name)
}
}
}

View File

@ -1,30 +1,18 @@
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 androidx.preference.PreferenceManager
import androidx.preference.SwitchPreference
import androidx.preference.SwitchPreferenceCompat
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import androidx.preference.*
import sushi.hardcore.droidfs.databinding.ActivitySettingsBinding
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)
@ -46,7 +34,7 @@ class SettingsActivity : BaseActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId){
android.R.id.home -> {
onBackPressedDispatcher.onBackPressed() //return to the previous fragment rather than the activity
onBackPressed() //return to the previous fragment rather than the activity
true
}
else -> super.onOptionsItemSelected(item)
@ -72,7 +60,7 @@ class SettingsActivity : BaseActivity() {
showMaxSizeDialog()
} else {
with(sharedPrefs.edit()) {
putLong(Constants.THUMBNAIL_MAX_SIZE_KEY, value)
putLong(ConstValues.THUMBNAIL_MAX_SIZE_KEY, value)
apply()
}
maxSizePreference.summary = PathUtils.formatSize(size)
@ -91,37 +79,19 @@ class SettingsActivity : BaseActivity() {
}
}
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)
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext())
findPreference<ListPreference>("color")?.setOnPreferenceChangeListener { _, _ ->
refreshTheme()
findPreference<ListPreference>("theme")?.setOnPreferenceChangeListener { _, newValue ->
(activity as BaseActivity).onThemeChanged(newValue as String)
true
}
findPreference<SwitchPreferenceCompat>("black_theme")?.setOnPreferenceChangeListener { _, _ ->
refreshTheme()
true
}
findPreference<Preference>(Constants.THUMBNAIL_MAX_SIZE_KEY)?.let {
findPreference<Preference>(ConstValues.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
ConstValues.THUMBNAIL_MAX_SIZE_KEY, ConstValues.DEFAULT_THUMBNAIL_MAX_SIZE
)*1000)
)
maxSizePreference.setOnPreferenceClickListener {
@ -129,10 +99,6 @@ class SettingsActivity : BaseActivity() {
false
}
}
findPreference<Preference>("logcat")?.setOnPreferenceClickListener { _ ->
startActivity(Intent(requireContext(), LogcatActivity::class.java))
true
}
}
}
@ -160,7 +126,7 @@ class SettingsActivity : BaseActivity() {
if (errorMsg == null) {
true
} else {
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).themeValue)
.setTitle(R.string.error)
.setMessage(errorMsg)
.setPositiveButton(R.string.ok, null)
@ -171,68 +137,6 @@ class SettingsActivity : BaseActivity() {
true
}
}
val switchBackground = findPreference<SwitchPreference>("usf_background")!!
val switchKeepOpen = findPreference<SwitchPreference>("usf_keep_open")!!
val switchExternalOpen = findPreference<SwitchPreference>("usf_open")!!
val switchExpose = findPreference<SwitchPreference>("usf_expose")!!
val switchSafWrite = findPreference<SwitchPreference>("usf_saf_write")!!
fun onUsfBackgroundChanged(usfBackground: Boolean) {
fun updateSwitchPreference(switch: SwitchPreference) = with (switch) {
isChecked = isChecked && usfBackground
isEnabled = usfBackground
onPreferenceChangeListener?.onPreferenceChange(switch, isChecked)
}
updateSwitchPreference(switchKeepOpen)
updateSwitchPreference(switchExpose)
}
onUsfBackgroundChanged(switchBackground.isChecked)
fun updateSafWrite(usfOpen: Boolean? = null, usfExpose: Boolean? = null) {
switchSafWrite.isEnabled = usfOpen ?: switchExternalOpen.isChecked || usfExpose ?: switchExpose.isChecked
}
updateSafWrite()
switchBackground.setOnPreferenceChangeListener { _, checked ->
onUsfBackgroundChanged(checked as Boolean)
true
}
switchExternalOpen.setOnPreferenceChangeListener { _, checked ->
updateSafWrite(usfOpen = checked as Boolean)
true
}
switchExpose.setOnPreferenceChangeListener { _, checked ->
updateSafWrite(usfExpose = checked as Boolean)
VolumeProvider.notifyRootsChanged(requireContext())
true
}
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
if (checked as Boolean) {
(requireActivity() as SettingsActivity).notificationPermissionHelper.askAndRun {
requireContext().let {
if (AndroidUtils.isServiceRunning(it, KeepAliveService::class.java)) {
ContextCompat.startForegroundService(it, Intent(it, KeepAliveService::class.java).apply {
action = KeepAliveService.ACTION_FOREGROUND
})
}
}
}
}
true
}
findPreference<ListPreference>("export_method")!!.setOnPreferenceChangeListener { _, newValue ->
if (newValue as String == "memory" && !Compat.isMemFileSupported()) {
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.memfd_create_unsupported, Compat.MEMFD_CREATE_MINIMUM_KERNEL_VERSION))
.setPositiveButton(R.string.ok, null)
.show()
return@setOnPreferenceChangeListener false
}
EncryptedFileProvider.exportMethod = EncryptedFileProvider.ExportMethod.parse(newValue)
true
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -2,7 +2,6 @@ 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
@ -10,212 +9,117 @@ import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 6) {
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, ConstValues.VOLUME_DATABASE_NAME, null, 4) {
companion object {
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"
}
const val TABLE_NAME = "Volumes"
const val COLUMN_NAME = "name"
const val COLUMN_HIDDEN = "hidden"
const val COLUMN_TYPE = "type"
const val COLUMN_HASH = "hash"
const val COLUMN_IV = "iv"
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")
}
}
private fun contentValuesFromVolume(volume: SavedVolume): ContentValues {
val contentValues = ContentValues()
contentValues.put(COLUMN_NAME, volume.name)
contentValues.put(COLUMN_HIDDEN, volume.isHidden)
contentValues.put(COLUMN_TYPE, byteArrayOf(volume.type))
contentValues.put(COLUMN_HASH, volume.encryptedHash)
contentValues.put(COLUMN_IV, volume.iv)
return contentValues
}
cursor.close()
}
private fun getNewVolumePath(volumeName: String): File {
return File(
VolumeData.getFullPath(volumeName, true, context.filesDir.path)
).canonicalFile
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
"$COLUMN_NAME TEXT PRIMARY KEY," +
"$COLUMN_HIDDEN SHORT," +
"$COLUMN_TYPE BLOB," +
"$COLUMN_HASH BLOB," +
"$COLUMN_IV BLOB" +
");"
)
File(context.filesDir, SavedVolume.VOLUMES_DIRECTORY).mkdir()
}
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)
// 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
// Moving hidden volumes to the "volumes" directory
if (File(context.filesDir, SavedVolume.VOLUMES_DIRECTORY).mkdir()) {
val cursor = db.query(
TABLE_NAME,
arrayOf(COLUMN_NAME),
"$COLUMN_HIDDEN=?",
arrayOf("1"),
null,
null,
null
)
while (cursor.moveToNext()) {
volumeNames[i++] = cursor.getString(0)
val volumeName = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME))
File(
PathUtils.pathJoin(
context.filesDir.path,
volumeName
)
).renameTo(
File(
SavedVolume(
volumeName,
true,
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE
).getFullPath(context.filesDir.path)
).canonicalFile
)
}
cursor.close()
if (volumeNames.isEmpty()) {
db.execSQL("DROP TABLE $TABLE_NAME;")
createTable(db)
} else {
db.execSQL("ALTER TABLE $TABLE_NAME RENAME TO OLD;")
createTable(db)
val uuidsValues = volumeNames.indices.joinToString(", ") { "('${VolumeData.newUuid()}', ?)" }
// add uuids to old data
db.execSQL(
"INSERT INTO $TABLE_NAME " +
"WITH uuids($COLUMN_UUID, $COLUMN_NAME) AS (VALUES $uuidsValues) " +
"SELECT $COLUMN_UUID, OLD.$COLUMN_NAME, $COLUMN_HIDDEN, $COLUMN_TYPE, $COLUMN_HASH, $COLUMN_IV " +
"FROM OLD JOIN uuids ON OLD.name = uuids.name;",
volumeNames
)
db.execSQL("DROP TABLE OLD;")
}
}
}
private fun extractVolumeData(cursor: Cursor): VolumeData {
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))
)
}
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 getVolume(volumeName: String, isHidden: Boolean): VolumeData? {
val cursor = getVolumeCursor(volumeName, isHidden)
val volumeData = if (cursor.moveToNext()) {
extractVolumeData(cursor)
} else {
null
Log.e("VolumeDatabase", "Volumes directory creation failed while upgrading")
}
cursor.close()
return volumeData
}
fun isVolumeSaved(volumeName: String, isHidden: Boolean): Boolean {
val cursor = getVolumeCursor(volumeName, isHidden)
val cursor = readableDatabase.query(TABLE_NAME,
arrayOf(COLUMN_NAME), "$COLUMN_NAME=? AND $COLUMN_HIDDEN=?",
arrayOf(volumeName, (if (isHidden) 1 else 0).toString()),
null, null, null
)
val result = cursor.count > 0
cursor.close()
return result
}
fun saveVolume(volume: VolumeData): Boolean {
fun saveVolume(volume: SavedVolume): 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 (writableDatabase.insert(TABLE_NAME, null, contentValuesFromVolume(volume)) == 0.toLong())
}
return false
}
fun getVolumes(): List<VolumeData> {
val list: MutableList<VolumeData> = ArrayList()
fun getVolumes(): List<SavedVolume> {
val list: MutableList<SavedVolume> = ArrayList()
val cursor = readableDatabase.rawQuery("SELECT * FROM $TABLE_NAME", null)
while (cursor.moveToNext()){
list.add(extractVolumeData(cursor))
list.add(
SavedVolume(
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))
)
)
}
cursor.close()
return list
}
fun isHashSaved(volume: VolumeData): Boolean {
val cursor = readableDatabase.rawQuery("SELECT $COLUMN_HASH FROM $TABLE_NAME WHERE $COLUMN_UUID=?", arrayOf(volume.uuid))
fun isHashSaved(volumeName: String): Boolean {
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME, COLUMN_HASH), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null)
var isHashSaved = false
if (cursor.moveToNext()) {
if (cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)) != null) {
@ -226,34 +130,33 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
return isHashSaved
}
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 addHash(volume: SavedVolume): Boolean {
return writableDatabase.update(TABLE_NAME, contentValuesFromVolume(volume), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
}
fun removeHash(volume: VolumeData): Boolean {
fun removeHash(volume: SavedVolume): Boolean {
return writableDatabase.update(
TABLE_NAME,
ContentValues().apply {
put(COLUMN_HASH, null as ByteArray?)
put(COLUMN_IV, null as ByteArray?)
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)
) > 0
TABLE_NAME, contentValuesFromVolume(
SavedVolume(
volume.name,
volume.isHidden,
volume.type,
null,
null
)
), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
}
fun renameVolume(volume: VolumeData, newName: String): Boolean {
return writableDatabase.update(
TABLE_NAME,
fun renameVolume(oldName: String, newName: String): Boolean {
return writableDatabase.update(TABLE_NAME,
ContentValues().apply {
put(COLUMN_NAME, newName)
},
"$COLUMN_UUID=?", arrayOf(volume.uuid)
"$COLUMN_NAME=?",arrayOf(oldName)
) > 0
}
fun removeVolume(volume: VolumeData): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
fun removeVolume(volumeName: String): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_NAME=?", arrayOf(volumeName)) > 0
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -40,12 +40,6 @@ abstract class SelectableAdapter<T>(private val onSelectionChanged: (Int) -> Uni
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)) {

View File

@ -8,46 +8,32 @@ 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.SavedVolume
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) {
) : SelectableAdapter<SavedVolume>(listener::onSelectionChanged) {
private val inflater: LayoutInflater = LayoutInflater.from(context)
lateinit var volumes: List<VolumeData>
lateinit var volumes: List<SavedVolume>
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 onVolumeItemClick(volume: SavedVolume, position: Int)
fun onVolumeItemLongClick()
}
override fun getItems(): List<VolumeData> {
override fun getItems(): List<SavedVolume> {
return volumes
}
@ -76,7 +62,7 @@ class VolumeAdapter(
false
}
fun onVolumeDataChanged(position: Int) {
fun onVolumeChanged(position: Int) {
reloadVolumes()
notifyItemChanged(position)
}
@ -93,26 +79,23 @@ class VolumeAdapter(
fun bind(position: Int) {
val volume = volumes[position]
itemView.findViewById<TextView>(R.id.text_volume_name).text = volume.shortName
itemView.findViewById<ImageView>(R.id.image_icon).setImageResource(R.drawable.icon_volume)
itemView.findViewById<TextView>(R.id.text_path).text = if (volume.isHidden)
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
})
)
itemView.findViewById<ImageView>(R.id.icon_fingerprint).visibility = if (volume.encryptedHash == null) {
View.GONE
} else {
View.VISIBLE
}
itemView.findViewById<TextView>(R.id.text_read_only).apply {
visibility = if (volume.canWrite(context.filesDir.path))
View.GONE
else {
View.VISIBLE
}
}
setSelectable(itemView.findViewById<LinearLayout>(R.id.selectable_container), itemView, layoutPosition)
}
}

View File

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

View File

@ -2,52 +2,42 @@ 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.BaseActivity
import sushi.hardcore.droidfs.ConstValues
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.databinding.ActivityAddVolumeBinding
import sushi.hardcore.droidfs.explorers.ExplorerRouter
class AddVolumeActivity: BaseActivity() {
companion object {
const val RESULT_USER_BACK = 10
const val RESULT_VOLUME_ADDED = 1
const val RESULT_HASH_STORAGE_RESET = 2
}
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),
SelectPathFragment.newInstance(themeValue),
)
.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)
else
finish()
}
}
return super.onOptionsItemSelected(item)
}
@ -62,34 +52,24 @@ class AddVolumeActivity: BaseActivity() {
)
}
fun startExplorer(volumeId: Int, volumeShortName: String) {
startActivity(explorerRouter.getExplorerIntent(volumeId, volumeShortName))
fun onSelectedAlreadySavedVolume() {
finish()
}
fun onVolumeAdded() {
setResult(RESULT_USER_BACK)
fun onVolumeAdded(hashStorageReset: Boolean) {
setResult(if (hashStorageReset) RESULT_HASH_STORAGE_RESET else RESULT_VOLUME_ADDED)
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) {
fun createVolume(volumePath: String, isHidden: Boolean) {
supportFragmentManager
.beginTransaction()
.replace(
R.id.fragment_container, CreateVolumeFragment.newInstance(
theme,
themeValue,
volumePath,
isHidden,
rememberVolume,
sharedPrefs.getBoolean(Constants.PIN_PASSWORDS_KEY, false),
sharedPrefs.getBoolean(ConstValues.PIN_PASSWORDS_KEY, false),
sharedPrefs.getBoolean("usf_fingerprint", false),
)
)

View File

@ -7,70 +7,43 @@ import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView
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.*
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.util.WidgetUtil
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File
import java.util.Arrays
import java.util.*
import kotlin.collections.ArrayList
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_PIN_PASSWORDS = ConstValues.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,
themeValue: String,
volumePath: String,
isHidden: Boolean,
rememberVolume: Boolean,
pinPasswords: Boolean,
usfFingerprint: Boolean,
): CreateVolumeFragment {
return CreateVolumeFragment().apply {
arguments = Bundle().apply {
putParcelable(KEY_THEME_VALUE, theme)
putString(KEY_THEME_VALUE, themeValue)
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)
}
@ -79,11 +52,10 @@ class CreateVolumeFragment: Fragment() {
}
private lateinit var binding: FragmentCreateVolumeBinding
private lateinit var theme: Theme
private val fileSystemInfos = ArrayList<FileSystemInfo>(2)
private var themeValue = ConstValues.DEFAULT_THEME_VALUE
private val volumeTypes = ArrayList<String>(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
@ -100,25 +72,31 @@ class CreateVolumeFragment: Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val pinPasswords = requireArguments().let { arguments ->
theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
arguments.getString(KEY_THEME_VALUE)?.let { themeValue = it }
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)
fingerprintProtector = FingerprintProtector.new(requireActivity(), themeValue, volumeDatabase)
}
if (!rememberVolume || !usfFingerprint || fingerprintProtector == null) {
if (!usfFingerprint || fingerprintProtector == null) {
binding.checkboxSavePassword.visibility = View.GONE
}
if (!BuildConfig.GOCRYPTFS_DISABLED) {
fileSystemInfos.add(GOCRYPTFS_INFO)
volumeTypes.add(resources.getString(R.string.gocryptfs))
}
if (!BuildConfig.CRYFS_DISABLED) {
fileSystemInfos.add(CRYFS_INFO)
volumeTypes.add(resources.getString(R.string.cryfs))
}
binding.spinnerVolumeType.adapter = ArrayAdapter(
requireContext(),
android.R.layout.simple_spinner_item,
volumeTypes
).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
val encryptionCipherAdapter = ArrayAdapter(
requireContext(),
@ -127,29 +105,23 @@ class CreateVolumeFragment: Fragment() {
).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.spinnerVolumeType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val ciphersArray = if (volumeTypes[position] == resources.getString(R.string.gocryptfs)) {
if (usfFingerprint && fingerprintProtector != null) {
binding.checkboxSavePassword.visibility = View.VISIBLE
}
R.array.gocryptfs_encryption_ciphers
} else {
binding.checkboxSavePassword.visibility = View.GONE
R.array.cryfs_encryption_ciphers
}
with(encryptionCipherAdapter) {
clear()
addAll(resources.getStringArray(ciphersArray).asList())
}
binding.radioGroupFilesystems.addView(root)
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
binding.spinnerCipher.adapter = encryptionCipherAdapter
if (pinPasswords) {
@ -171,137 +143,102 @@ class CreateVolumeFragment: Fragment() {
(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
private fun saveVolume(success: Boolean, volumeType: Byte): SavedVolume? {
return if (success) {
val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath
val volume = SavedVolume(volumeName, isHiddenVolume, volumeType)
volumeDatabase.apply {
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
removeVolume(volumeName)
saveVolume(volume)
}
volume
} else {
null
}
return -1
}
private fun createVolume() {
val password = UIUtils.encodeEditTextContent(binding.editPassword)
val passwordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
val password = WidgetUtil.encodeEditTextContent(binding.editPassword)
val passwordConfirm = WidgetUtil.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 {
var returnedHash: ByteArray? = null
if (binding.checkboxSavePassword.isChecked)
returnedHash = ByteArray(GocryptfsVolume.KeyLen)
object: LoadingTask<SavedVolume?>(requireActivity() as AppCompatActivity, themeValue, R.string.loading_msg_create) {
override suspend fun doTask(): SavedVolume? {
val volumeFile = File(volumePath)
if (!volumeFile.exists())
volumeFile.mkdirs()
val result = if (fileSystemInfos[getSelectedFileSystemIndex()] == GOCRYPTFS_INFO) {
val volume = if (volumeTypes[binding.spinnerVolumeType.selectedItemPosition] == resources.getString(R.string.gocryptfs)) {
val xchacha = when (binding.spinnerCipher.selectedItemPosition) {
0 -> -1 // auto
1 -> 0 // AES-GCM
else -> 1 // XChaCha20-Poly1305
0 -> 0
1 -> 1
else -> -1
}
generateResult(GocryptfsVolume.createAndOpenVolume(
saveVolume(GocryptfsVolume.createVolume(
volumePath,
password,
false,
xchacha,
returnedHash?.apply {
value = ByteArray(GocryptfsVolume.KeyLen)
}?.value,
encryptedVolume,
GocryptfsVolume.ScryptDefaultLogN,
ConstValues.CREATOR,
returnedHash
), EncryptedVolume.GOCRYPTFS_VOLUME_TYPE)
} else {
encryptedVolume.value = CryfsVolume.create(
saveVolume(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)
resources.getStringArray(R.array.cryfs_encryption_ciphers)[binding.spinnerCipher.selectedItemPosition]
), EncryptedVolume.CRYFS_VOLUME_TYPE)
}
Arrays.fill(password, 0)
return result
return volume
}
}.startTask(lifecycleScope) { result ->
if (result.compareTo(-1) == 0) {
CustomAlertDialogBuilder(requireContext(), theme)
}.startTask(lifecycleScope) { volume ->
if (volume == null) {
CustomAlertDialogBuilder(requireContext(), themeValue)
.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) {
if (binding.checkboxSavePassword.isChecked && returnedHash != null) {
fingerprintProtector!!.let {
it.listener = object : FingerprintProtector.Listener {
override fun onHashStorageReset() {
hashStorageReset = true
// retry
it.savePasswordHash(volume, returnedHash.value!!)
it.savePasswordHash(volume, returnedHash)
}
override fun onPasswordHashDecrypted(hash: ByteArray) {} // shouldn't happen here
override fun onPasswordHashSaved() {
Arrays.fill(returnedHash.value!!, 0)
onVolumeCreated(volumeId, volume.shortName)
Arrays.fill(returnedHash, 0)
onVolumeCreated()
}
override fun onFailed(pending: Boolean) {
if (!pending) {
Arrays.fill(returnedHash.value!!, 0)
onVolumeCreated(volumeId, volume.shortName)
Arrays.fill(returnedHash, 0)
onVolumeCreated()
}
}
}
it.savePasswordHash(volume, returnedHash.value!!)
it.savePasswordHash(volume, returnedHash)
}
} else {
onVolumeCreated(volumeId, volume.shortName)
}
} else onVolumeCreated()
}
}
}
}
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()
private fun onVolumeCreated() {
(activity as AddVolumeActivity).onVolumeAdded(hashStorageReset)
}
}

View File

@ -2,57 +2,35 @@ 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.*
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 {
fun newInstance(themeValue: String): SelectPathFragment {
return SelectPathFragment().apply {
arguments = Bundle().apply {
putParcelable(KEY_THEME_VALUE, theme)
putBoolean(KEY_PICK_MODE, pickMode)
putString(KEY_THEME_VALUE, themeValue)
}
}
}
@ -61,9 +39,9 @@ class SelectPathFragment: Fragment() {
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()
PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue)
else
CustomAlertDialogBuilder(requireContext(), theme)
CustomAlertDialogBuilder(requireContext(), themeValue)
.setTitle(R.string.storage_perm_denied)
.setMessage(R.string.storage_perm_denied_msg)
.setCancelable(false)
@ -74,16 +52,8 @@ class SelectPathFragment: Fragment() {
if (uri != null)
onDirectoryPicked(uri)
}
private lateinit var app: VolumeManagerApp
private lateinit var theme: Theme
private var themeValue = ConstValues.DEFAULT_THEME_VALUE
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,
@ -95,289 +65,196 @@ class SelectPathFragment: Fragment() {
}
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)
arguments.getString(KEY_THEME_VALUE)?.let { themeValue = it }
}
volumeDatabase = VolumeDatabase(requireContext())
filesDir = requireContext().filesDir.path
binding.containerHiddenVolume.setOnClickListener {
binding.switchHiddenVolume.performClick()
}
binding.switchHiddenVolume.setOnClickListener {
updateUi()
showRightSection()
}
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(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.READ_EXTERNAL_STORAGE
) + ContextCompat.checkSelfPermission(
) +
ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
) {
launchPickDirectory()
} else {
app.isStartingExternalApp = true
)
PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue)
else
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
)
} else
PathUtils.safePickDirectory(pickDirectory, requireContext(), themeValue)
}
var isVolumeAlreadySaved = false
var volumeAction: Action? = null
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)
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
isVolumeAlreadySaved = volumeDatabase.isVolumeSaved(s.toString(), binding.switchHiddenVolume.isChecked)
if (isVolumeAlreadySaved)
binding.textWarning.apply {
text = getString(R.string.volume_alread_saved)
visibility = View.VISIBLE
}
else
binding.textWarning.visibility = View.GONE
val path = File(getCurrentVolumePath())
volumeAction = if (path.isDirectory)
if (path.list()?.isEmpty() == true) Action.CREATE else Action.ADD
else
Action.CREATE
binding.buttonAction.text = getString(when (volumeAction) {
Action.CREATE -> R.string.create
else -> R.string.add_volume
})
}
})
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() }
binding.editVolumeName.setOnEditorActionListener { _, _, _ -> onPathSelected(isVolumeAlreadySaved, volumeAction); true }
binding.buttonAction.setOnClickListener { onPathSelected(isVolumeAlreadySaved, volumeAction) }
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
(activity as AddVolumeActivity).onFragmentLoaded(true)
showRightSection()
}
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)
private fun showRightSection() {
if (binding.switchHiddenVolume.isChecked) {
binding.textLabel.text = requireContext().getString(R.string.volume_name_label)
binding.editVolumeName.hint = requireContext().getString(R.string.volume_name_hint)
binding.buttonPickDirectory.visibility = View.GONE
} else {
binding.textLabel.text = getString(R.string.volume_path_label)
binding.editVolumeName.hint = getString(R.string.volume_path_hint)
binding.textLabel.text = requireContext().getString(R.string.volume_path_label)
binding.editVolumeName.hint = requireContext().getString(R.string.volume_path_hint)
binding.buttonPickDirectory.visibility = View.VISIBLE
}
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)
if (path != null)
binding.editVolumeName.setText(path)
else
CustomAlertDialogBuilder(requireContext(), themeValue)
.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())
SavedVolume.getHiddenVolumeFullPath(requireContext().filesDir.path, 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)
private fun onPathSelected(isVolumeAlreadySaved: Boolean, volumeAction: Action?) {
if (isVolumeAlreadySaved) {
(activity as AddVolumeActivity).onSelectedAlreadySavedVolume()
} else {
if (binding.switchHiddenVolume.isChecked && volumeAction == Action.CREATE) {
CustomAlertDialogBuilder(requireContext(), themeValue)
.setTitle(R.string.warning)
.setMessage(R.string.hidden_volume_warning)
.setPositiveButton(R.string.ok) { _, _ ->
onNewVolumeSelected(currentVolumeValue, isHidden)
addVolume(volumeAction)
}
.show()
} else {
onNewVolumeSelected(currentVolumeValue, isHidden)
}
} else {
with (activity as AddVolumeActivity) {
if (volumeAction!! == Action.OPEN) {
openVolume(currentVolumeData!!, true)
} else {
onVolumeAdded()
}
addVolume(volumeAction)
}
}
}
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
)
}
private fun addVolume(volumeAction: Action?) {
val currentVolumeValue = binding.editVolumeName.text.toString()
val isHidden = binding.switchHiddenVolume.isChecked
if (currentVolumeValue.isEmpty()) {
Toast.makeText(
requireContext(),
if (isHidden) R.string.enter_volume_name else R.string.enter_volume_path,
Toast.LENGTH_SHORT
).show()
} else if (isHidden && currentVolumeValue.contains(PathUtils.SEPARATOR)) {
Toast.makeText(requireContext(), R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
} 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)
val volumePath = getCurrentVolumePath()
when (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)
}
Action.ADD -> {
val volumeType = EncryptedVolume.getVolumeType(volumePath)
if (volumeType < 0) {
CustomAlertDialogBuilder(requireContext(), themeValue)
.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(), themeValue)
.setTitle(R.string.warning)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> addVolume(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 {
addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
}
}
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)
val dialog = CustomAlertDialogBuilder(requireContext(), themeValue)
.setTitle(R.string.error)
.setPositiveButton(R.string.ok, null)
@SuppressLint("InflateParams")
@ -392,17 +269,8 @@ class SelectPathFragment: Fragment() {
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()
}
}
private fun addVolume(volumeName: String, isHidden: Boolean, volumeType: Byte) {
volumeDatabase.saveVolume(SavedVolume(volumeName, isHidden, volumeType))
(activity as AddVolumeActivity).onVolumeAdded(false)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,19 @@
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.widget.ImageButton
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.addCallback
import androidx.core.view.isVisible
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
@ -20,36 +22,26 @@ 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 kotlinx.coroutines.withContext
import sushi.hardcore.droidfs.BaseActivity
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.ConstValues
import sushi.hardcore.droidfs.ConstValues.isAudio
import sushi.hardcore.droidfs.ConstValues.isImage
import sushi.hardcore.droidfs.ConstValues.isPDF
import sushi.hardcore.droidfs.ConstValues.isText
import sushi.hardcore.droidfs.ConstValues.isVideo
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter
import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.content_providers.ExternalProvider
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
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.file_viewers.*
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.util.finishOnClose
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -59,7 +51,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
private var foldersFirst = true
private var mapFolders = true
private var currentSortOrderIndex = 0
protected var volumeId = -1
protected lateinit var encryptedVolume: EncryptedVolume
private lateinit var volumeName: String
private lateinit var explorerViewModel: ExplorerViewModel
@ -69,42 +60,38 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
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
protected lateinit var app: VolumeManagerApp
private var isCreating = true
protected var isStartingActivity = false
private var usf_open = false
protected var usf_keep_open = false
private lateinit var linearLayoutManager: LinearLayoutManager
private var isUsingListLayout = true
private lateinit var layoutIcon: ImageButton
private lateinit var titleText: TextView
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)
volumeName = intent.getStringExtra("volumeName") ?: ""
volumeId = intent.getIntExtra("volumeId", -1)
encryptedVolume = app.volumeManager.getVolume(volumeId)!!.also { finishOnClose(it) }
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false)
volumeName = intent.getStringExtra("volume_name") ?: ""
encryptedVolume = intent.getParcelableExtra("volume")!!
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(Constants.SORT_ORDER_KEY, "name"))
currentSortOrderIndex = resources.getStringArray(R.array.sort_orders_values).indexOf(sharedPrefs.getString(ConstValues.SORT_ORDER_KEY, "name"))
init()
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)
@ -125,28 +112,16 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
null
},
this,
sharedPrefs.getLong(Constants.THUMBNAIL_MAX_SIZE_KEY, Constants.DEFAULT_THUMBNAIL_MAX_SIZE)*1000,
sharedPrefs.getLong(ConstValues.THUMBNAIL_MAX_SIZE_KEY, ConstValues.DEFAULT_THUMBNAIL_MAX_SIZE)*1000,
)
explorerViewModel = ViewModelProvider(this).get(ExplorerViewModel::class.java)
currentDirectoryPath = explorerViewModel.currentDirectoryPath
setCurrentPath(currentDirectoryPath)
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()
@ -182,63 +157,42 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
protected open fun init() {
setContentView(R.layout.activity_explorer)
setContentView(R.layout.activity_explorer_base)
}
protected open fun bindFileOperationService() {
FileOperationService.bind(this) {
fileOperationService = it
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.setEncryptedVolume(encryptedVolume)
}
override fun onServiceDisconnected(arg0: ComponentName) {
}
}, Context.BIND_AUTO_CREATE)
}
}
private fun startFileViewer(cls: Class<*>, filePath: String) {
private fun startFileViewer(cls: Class<*>, filePath: String){
val intent = Intent(this, cls).apply {
putExtra("path", filePath)
putExtra("volumeId", volumeId)
putExtra("volume", encryptedVolume)
putExtra("sortOrder", sortOrderValues[currentSortOrderIndex])
}
isStartingActivity = true
startActivity(intent)
}
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 openWithExternalApp(fullPath: String){
isStartingActivity = true
ExternalProvider.open(this, themeValue, encryptedVolume, fullPath)
}
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
private fun showOpenAsDialog(path: String) {
val adapter = OpenAsDialogAdapter(this, usf_open)
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setSingleChoiceItems(adapter, -1) { dialog, which ->
when (adapter.getItem(which)) {
"image" -> startFileViewer(ImageViewer::class.java, path)
@ -247,7 +201,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
"pdf" -> startFileViewer(PdfViewer::class.java, path)
"text" -> startFileViewer(TextEditor::class.java, path)
"external" -> if (usf_open) {
openWithExternalApp(path, explorerElement.stat.size)
openWithExternalApp(path)
}
}
dialog.dismiss()
@ -257,27 +211,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
.show()
}
protected fun createNewFile(callback: (Long) -> Unit) {
EditTextDialog(this, R.string.enter_file_name) {
if (it.isEmpty()) {
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
createNewFile(callback)
} else {
val filePath = PathUtils.pathJoin(currentDirectoryPath, it)
val handleID = encryptedVolume.openFileWriteMode(filePath)
if (handleID == -1L) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.file_creation_failed)
.setPositiveButton(R.string.ok, null)
.show()
} else {
callback(handleID)
}
}
}.show()
}
private fun setVolumeNameTitle() {
titleText.text = getString(R.string.volume, volumeName)
}
@ -300,22 +233,22 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
explorerElements[position].isParentFolder -> {
setCurrentPath(PathUtils.getParentPath(currentDirectoryPath))
}
FileTypes.isImage(fullPath) -> {
isImage(fullPath) -> {
startFileViewer(ImageViewer::class.java, fullPath)
}
FileTypes.isVideo(fullPath) -> {
isVideo(fullPath) -> {
startFileViewer(VideoPlayer::class.java, fullPath)
}
FileTypes.isText(fullPath) -> {
isText(fullPath) -> {
startFileViewer(TextEditor::class.java, fullPath)
}
FileTypes.isPDF(fullPath) -> {
isPDF(fullPath) -> {
startFileViewer(PdfViewer::class.java, fullPath)
}
FileTypes.isAudio(fullPath) -> {
isAudio(fullPath) -> {
startFileViewer(AudioPlayer::class.java, fullPath)
}
else -> showOpenAsDialog(explorerElements[position])
else -> showOpenAsDialog(fullPath)
}
}
invalidateOptionsMenu()
@ -330,16 +263,19 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
invalidateOptionsMenu()
}
private fun displayExplorerElements() {
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
private fun displayExplorerElements(totalSize: Long) {
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
synchronized(this) {
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
}
unselectAll(false)
loader.isVisible = false
recycler_view_explorer.isVisible = true
explorerAdapter.explorerElements = explorerElements
val sharedPrefsEditor = sharedPrefs.edit()
sharedPrefsEditor.putString(ConstValues.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
sharedPrefsEditor.apply()
}
private suspend fun recursiveSetSize(directory: ExplorerElement) {
yield()
private fun recursiveSetSize(directory: ExplorerElement) {
for (child in encryptedVolume.readDir(directory.fullPath) ?: return) {
if (child.isDirectory) {
recursiveSetSize(child)
@ -363,16 +299,15 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
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)
)
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) {
synchronized(this) {
explorerElements = encryptedVolume.readDir(path) ?: return
if (path != "/") {
explorerElements.add(
0,
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
)
}
}
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.GONE
currentDirectoryPath = path
@ -380,47 +315,55 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
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)
lifecycleScope.launch {
var totalSize: Long = 0
withContext(Dispatchers.IO) {
synchronized(this@BaseExplorerActivity) {
for (element in explorerElements) {
if (element.isDirectory) {
recursiveSetSize(element)
}
totalSize += element.stat.size
}
}
totalSize += element.stat.size
}
displayExplorerElements(totalSize)
onDisplayed?.invoke()
}
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 })
)
displayExplorerElements(explorerElements.filter { !it.isParentFolder }.sumOf { it.stat.size })
onDisplayed?.invoke()
}
}
private fun askLockVolume() {
CustomAlertDialogBuilder(this, theme)
private fun askCloseVolume() {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.warning)
.setMessage(R.string.ask_lock_volume)
.setPositiveButton(R.string.ok) { _, _ ->
app.volumeManager.closeVolume(volumeId)
finish()
}
.setMessage(R.string.ask_close_volume)
.setPositiveButton(R.string.ok) { _, _ -> closeVolumeOnUserExit() }
.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 (!encryptedVolume.mkdir(PathUtils.pathJoin(currentDirectoryPath, folderName))) {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.error_mkdir)
.setPositiveButton(R.string.ok, null)
@ -438,10 +381,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}.show()
}
protected fun checkPathOverwrite(items: List<OperationFile>, dstDirectoryPath: String, callback: (List<OperationFile>?) -> Unit) {
protected fun checkPathOverwrite(items: ArrayList<OperationFile>, dstDirectoryPath: String, callback: (ArrayList<OperationFile>?) -> Unit) {
val srcDirectoryPath = items[0].parentPath
var ready = true
for (i in items.indices) {
for (i in 0 until items.size) {
val testDstPath: String
if (items[i].dstPath == null){
testDstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].srcPath))
@ -457,7 +400,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
if (!ready){
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.warning)
.setMessage(getString(
if (items[i].isDirectory) {
@ -475,7 +418,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
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) {
for (j in 0 until items.size){
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))
}
@ -502,38 +445,12 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
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) {
protected fun importFilesFromUris(uris: List<Uri>, callback: (String?) -> Unit) {
val items = ArrayList<OperationFile>()
for (uri in uris) {
val fileName = PathUtils.getFilenameFromURI(this, uri)
if (fileName == null) {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.error_retrieving_filename, uri))
.setPositiveButton(R.string.ok, null)
@ -547,10 +464,13 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
if (items.size > 0) {
checkPathOverwrite(items, currentDirectoryPath) { checkedItems ->
checkedItems?.let {
activityScope.launch {
val result = fileOperationService.importFilesFromUris(volumeId, checkedItems.map { it.dstPath!! }, uris)
onTaskResult(result, R.string.import_failed, onSuccess = callback)
setCurrentPath(currentDirectoryPath)
lifecycleScope.launch {
val taskResult = fileOperationService.importFilesFromUris(checkedItems.map { it.dstPath!! }, uris)
if (taskResult.cancelled) {
setCurrentPath(currentDirectoryPath)
} else {
callback(taskResult.failedItem)
}
}
}
}
@ -562,7 +482,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
} else {
if (!encryptedVolume.rename(PathUtils.pathJoin(currentDirectoryPath, old_name), PathUtils.pathJoin(currentDirectoryPath, new_name))) {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.rename_failed, old_name))
.setPositiveButton(R.string.ok, null)
@ -575,6 +495,14 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
private fun setMenuIconTint(menu: Menu, iconColor: Int, menuItemId: Int, drawableId: Int) {
menu.findItem(menuItemId)?.let {
it.icon = ContextCompat.getDrawable(this, drawableId)?.apply {
setTint(iconColor)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menu.findItem(R.id.rename).isVisible = false
menu.findItem(R.id.open_as)?.isVisible = false
@ -582,12 +510,11 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
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)
}
val iconColor = ContextCompat.getColor(this, R.color.neutralIconTint)
setMenuIconTint(menu, iconColor, R.id.sort, R.drawable.icon_sort)
setMenuIconTint(menu, iconColor, R.id.decrypt, R.drawable.icon_decrypt)
setMenuIconTint(menu, iconColor, 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
supportActionBar?.setDisplayHomeAsUpEnabled(!noItemSelected)
if (!noItemSelected) {
@ -611,17 +538,11 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
true
}
R.id.sort -> {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.sort_order)
.setSingleChoiceItems(sortOrderEntries, currentSortOrderIndex) { dialog, which ->
currentSortOrderIndex = which
// 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()
setCurrentPath(currentDirectoryPath)
dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
@ -639,41 +560,75 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
true
}
R.id.open_as -> {
showOpenAsDialog(explorerElements[explorerAdapter.selectedItems.first()])
showOpenAsDialog(
PathUtils.pathJoin(
currentDirectoryPath,
explorerElements[explorerAdapter.selectedItems.first()].name
)
)
true
}
R.id.external_open -> {
if (usf_open){
val explorerElement = explorerElements[explorerAdapter.selectedItems.first()]
openWithExternalApp(explorerElement.fullPath, explorerElement.stat.size)
openWithExternalApp(
PathUtils.pathJoin(
currentDirectoryPath,
explorerElements[explorerAdapter.selectedItems.first()].name
)
)
unselectAll()
}
true
}
R.id.close -> {
finish()
true
}
R.id.lock -> {
askLockVolume()
askCloseVolume()
true
}
else -> super.onOptionsItemSelected(item)
}
}
protected open fun closeVolumeOnUserExit() {
finish()
}
protected open fun closeVolumeOnDestroy() {
if (!encryptedVolume.isClosed()) {
encryptedVolume.close()
}
RestrictedFileProvider.wipeAll(this) //additional security
}
override fun onDestroy() {
super.onDestroy()
if (!isChangingConfigurations) { //activity won't be recreated
activityScope.cancel()
closeVolumeOnDestroy()
}
}
override fun onPause() {
super.onPause()
if (!isChangingConfigurations){
if (isStartingActivity){
isStartingActivity = false
} else if (!usf_keep_open){
finish()
}
}
}
override fun onResume() {
super.onResume()
if (app.isStartingExternalApp) {
TemporaryFileProvider.instance.wipe()
if (isCreating){
isCreating = false
} else {
if (encryptedVolume.isClosed()) {
finish()
} else {
isStartingActivity = false
ExternalProvider.removeFilesAsync(this)
setCurrentPath(currentDirectoryPath)
}
}
setCurrentPath(currentDirectoryPath)
}
}

View File

@ -6,18 +6,18 @@ import android.net.Uri
import android.view.Menu
import android.view.MenuItem
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.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.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
@ -32,171 +32,177 @@ 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 srcVolumeId = resultIntent.getIntExtra("volumeId", -1)
val srcEncryptedVolume = app.volumeManager.getVolume(srcVolumeId)!!
val remoteEncryptedVolume = resultIntent.getParcelableExtra<EncryptedVolume>("volume")!!
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){
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))
}
}
for (i in paths.indices) {
operationFiles.add(
OperationFile(paths[i], types[i])
)
if (types[i] == Stat.S_IFDIR) {
remoteEncryptedVolume.recursiveMapFiles(paths[i])?.forEach {
operationFiles.add(OperationFile.fromExplorerElement(it))
}
return operationFiles
}
}.startTask(lifecycleScope) { operationFiles ->
importFilesFromVolume(srcVolumeId, operationFiles)
}
}
} else {
importFilesFromVolume(srcVolumeId, arrayListOf(OperationFile(path, Stat.S_IFREG)))
operationFiles.add(
OperationFile(path, Stat.S_IFREG)
)
}
if (operationFiles.size > 0){
checkPathOverwrite(operationFiles, currentDirectoryPath) { items ->
if (items == null) {
remoteEncryptedVolume.close()
} else {
lifecycleScope.launch {
val failedItem = fileOperationService.copyElements(items, remoteEncryptedVolume)
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.success_import, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
setCurrentPath(currentDirectoryPath)
remoteEncryptedVolume.close()
}
}
}
} else {
remoteEncryptedVolume.close()
}
}
}
}
private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris != null) {
for (uri in uris) {
try {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
} catch (e: SecurityException) {
e.printStackTrace()
}
}
importFilesFromUris(uris) {
onImportComplete(uris)
importFilesFromUris(uris){ failedItem ->
onImportComplete(failedItem, uris)
}
}
}
private val pickExportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
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)
lifecycleScope.launch {
val result = fileOperationService.exportFiles(uri, explorerAdapter.selectedItems.map { i -> explorerElements[i] })
if (!result.cancelled) {
if (result.failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.success_export, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.export_failed, result.failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
}
}
}
unselectAll()
}
private val pickImportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { rootUri ->
rootUri?.let {
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)
lifecycleScope.launch {
val result = fileOperationService.importDirectory(checkedOperation[0].dstPath!!, tree)
if (result.taskResult.cancelled) {
setCurrentPath(currentDirectoryPath)
} else {
onImportComplete(result.taskResult.failedItem, result.uris, tree)
}
setCurrentPath(currentDirectoryPath)
}
}
}
}
}
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)
private fun onImportComplete(failedItem: String?, urisToWipe: List<Uri>, rootFile: DocumentFile? = null) {
if (failedItem == null){
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.success_import)
.setMessage("""
${getString(R.string.success_import_msg)}
${getString(R.string.ask_for_wipe)}
""".trimIndent())
.setPositiveButton(R.string.yes) { _, _ ->
lifecycleScope.launch {
val errorMsg = fileOperationService.wipeUris(urisToWipe, rootFile)
if (errorMsg == null) {
Toast.makeText(this@ExplorerActivity, R.string.wipe_successful, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.wipe_failed, errorMsg))
.setPositiveButton(R.string.ok, null)
.show()
}
}
}
}
.setNegativeButton(R.string.no, null)
.show()
} else {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
}
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()
setCurrentPath(currentDirectoryPath)
}
override fun init() {
super.init()
onBackPressedDispatcher.addCallback(this) {
if (currentItemAction != ItemsActions.NONE) {
cancelItemAction()
invalidateOptionsMenu()
} else {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
findViewById<FloatingActionButton>(R.id.fab).setOnClickListener {
binding = ActivityExplorerBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.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_transfer),
listOf("importFromOtherVolumes", R.string.import_from_other_volume, R.drawable.icon_transfert),
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_photo)
)
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setSingleChoiceItems(adapter, -1){ thisDialog, which ->
when (adapter.getItem(which)){
"importFromOtherVolumes" -> {
val intent = Intent(this, MainActivity::class.java)
intent.action = "pick"
intent.putExtra("volume", encryptedVolume)
isStartingActivity = true
pickFromOtherVolumes.launch(intent)
}
"importFiles" -> {
app.isStartingExternalApp = true
isStartingActivity = true
pickFiles.launch(arrayOf("*/*"))
}
"importFolder" -> {
app.isStartingExternalApp = true
isStartingActivity = true
pickImportDirectory.launch(null)
}
"createFile" -> {
createNewFile {
encryptedVolume.closeFile(it)
setCurrentPath(currentDirectoryPath)
invalidateOptionsMenu()
}
EditTextDialog(this, R.string.enter_file_name) {
createNewFile(it)
}.show()
}
"createFolder" -> {
openDialogCreateFolder()
@ -204,7 +210,8 @@ class ExplorerActivity : BaseExplorerActivity() {
"camera" -> {
val intent = Intent(this, CameraActivity::class.java)
intent.putExtra("path", currentDirectoryPath)
intent.putExtra("volumeId", volumeId)
intent.putExtra("volume", encryptedVolume)
isStartingActivity = true
startActivity(intent)
}
}
@ -224,12 +231,31 @@ class ExplorerActivity : BaseExplorerActivity() {
cancelItemAction()
}
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 = encryptedVolume.openFile(filePath)
if (handleID == -1L) {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.file_creation_failed)
.setPositiveButton(R.string.ok, null)
.show()
} else {
encryptedVolume.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 {
@ -280,6 +306,11 @@ class ExplorerActivity : BaseExplorerActivity() {
R.id.copy -> {
for (i in explorerAdapter.selectedItems){
itemsToProcess.add(OperationFile.fromExplorerElement(explorerElements[i]))
if (explorerElements[i].isDirectory){
encryptedVolume.recursiveMapFiles(explorerElements[i].fullPath)?.forEach {
itemsToProcess.add(OperationFile.fromExplorerElement(it))
}
}
}
currentItemAction = ItemsActions.COPY
unselectAll()
@ -287,31 +318,26 @@ class ExplorerActivity : BaseExplorerActivity() {
}
R.id.validate -> {
if (currentItemAction == ItemsActions.COPY){
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,
)
checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items ->
items?.let {
lifecycleScope.launch {
val failedItem = fileOperationService.copyElements(it.toMutableList() as ArrayList<OperationFile>)
if (!isFinishing) {
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.copy_success, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
setCurrentPath(currentDirectoryPath)
}
}
cancelItemAction()
invalidateOptionsMenu()
}
cancelItemAction()
invalidateOptionsMenu()
}
} else if (currentItemAction == ItemsActions.MOVE){
itemsToProcess.forEach {
@ -325,12 +351,17 @@ class ExplorerActivity : BaseExplorerActivity() {
toMove,
toClean,
) {
activityScope.launch {
onTaskResult(
fileOperationService.moveElements(volumeId, toMove, toClean),
R.string.move_failed,
R.string.move_success,
)
lifecycleScope.launch {
val failedItem = fileOperationService.moveElements(toMove, toClean)
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.move_success, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.move_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
setCurrentPath(currentDirectoryPath)
}
cancelItemAction()
@ -341,22 +372,9 @@ class ExplorerActivity : BaseExplorerActivity() {
}
R.id.delete -> {
val size = explorerAdapter.selectedItems.size
val dialog = CustomAlertDialogBuilder(this, theme)
val dialog = CustomAlertDialogBuilder(this, themeValue)
dialog.setTitle(R.string.warning)
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.setPositiveButton(R.string.ok) { _, _ -> removeSelectedItems() }
dialog.setNegativeButton(R.string.cancel, null)
if (size > 1) {
dialog.setMessage(getString(R.string.multiple_delete_confirm, explorerAdapter.selectedItems.size.toString()))
@ -370,30 +388,17 @@ class ExplorerActivity : BaseExplorerActivity() {
true
}
R.id.share -> {
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
val paths: MutableList<String> = ArrayList()
for (i in explorerAdapter.selectedItems) {
paths.add(explorerElements[i].fullPath)
}
isStartingActivity = true
ExternalProvider.share(this, themeValue, encryptedVolume, paths)
unselectAll()
true
}
R.id.decrypt -> {
app.isStartingExternalApp = true
isStartingActivity = true
pickExportDirectory.launch(null)
true
}
@ -409,7 +414,7 @@ class ExplorerActivity : BaseExplorerActivity() {
private fun checkMoveOverwrite(items: List<OperationFile>, callback: (List<OperationFile>?) -> Unit) {
for (item in items) {
if (encryptedVolume.pathExists(item.dstPath!!) && !item.overwriteConfirmed) {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.warning)
.setMessage(
getString(
@ -488,4 +493,38 @@ 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.explorerElements[i]
val fullPath = PathUtils.pathJoin(currentDirectoryPath, element.name)
if (element.isDirectory) {
val result = encryptedVolume.recursiveRemoveDirectory(fullPath)
result?.let{ failedItem = it }
} else {
if (!encryptedVolume.deleteFile(fullPath)) {
failedItem = fullPath
}
}
if (failedItem != null) {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
break
}
}
setCurrentPath(currentDirectoryPath) //refresh
}
}

View File

@ -2,21 +2,19 @@ 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.util.IntentUtils
import sushi.hardcore.droidfs.databinding.ActivityExplorerDropBinding
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() {
super.init()
findViewById<FloatingActionButton>(R.id.fab).setOnClickListener {
binding = ActivityExplorerDropBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.fab.setOnClickListener {
openDialogCreateFolder()
}
}
@ -32,54 +30,35 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
return when (item.itemId) {
R.id.validate -> {
val extras = intent.extras
val success = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
val errorMsg: String? = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
when (intent.action) {
Intent.ACTION_SEND -> {
val uri = IntentUtils.getParcelableExtra<Uri>(intent, Intent.EXTRA_STREAM)
val uri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
if (uri == null) {
false
getString(R.string.share_intent_parsing_failed)
} else {
importFilesFromUris(listOf(uri), ::onImported)
true
null
}
}
Intent.ACTION_SEND_MULTIPLE -> {
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)
}
val uris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
if (uris != null) {
importFilesFromUris(uris, ::onImported)
true
null
} else {
false
getString(R.string.share_intent_parsing_failed)
}
}
else -> false
else -> getString(R.string.share_intent_parsing_failed)
}
} 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 {
false
getString(R.string.share_intent_parsing_failed)
}
if (!success) {
CustomAlertDialogBuilder(this, theme)
errorMsg?.let {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.share_intent_parsing_failed)
.setMessage(it)
.setPositiveButton(R.string.ok, null)
.show()
}
@ -89,15 +68,23 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
}
}
private fun onImported() {
private fun onImported(failedItem: String?){
setCurrentPath(currentDirectoryPath)
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage(R.string.success_import_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ ->
finish()
}
.show()
if (failedItem == null) {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.success_import)
.setMessage(R.string.success_import_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok){_, _ ->
finish()
}
.show()
} else {
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
}
}

View File

@ -6,15 +6,14 @@ import android.view.Menu
import android.view.MenuItem
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils
class ExplorerActivityPick : BaseExplorerActivity() {
private var resultIntent = Intent()
private var isFinishingIntentionally = false
override fun init() {
setContentView(R.layout.activity_explorer_pick)
resultIntent.putExtra("volumeId", volumeId)
super.init()
resultIntent.putExtra("volume", encryptedVolume)
}
override fun bindFileOperationService() {
@ -22,18 +21,21 @@ class ExplorerActivityPick : BaseExplorerActivity() {
}
override fun onExplorerElementClick(position: Int) {
val wasSelecting = explorerAdapter.selectedItems.isNotEmpty()
if (explorerAdapter.selectedItems.isEmpty()) {
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()
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()
}
}
}
}
@ -78,4 +80,17 @@ class ExplorerActivityPick : BaseExplorerActivity() {
isFinishingIntentionally = true
finish()
}
override fun closeVolumeOnDestroy() {
if (!isFinishingIntentionally && !usf_keep_open){
intent.getParcelableExtra<EncryptedVolume>("destinationVolume")?.let { it.close() }
super.closeVolumeOnDestroy()
}
}
override fun closeVolumeOnUserExit() {
isFinishingIntentionally = true
super.closeVolumeOnUserExit()
super.closeVolumeOnDestroy()
}
}

View File

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

View File

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

View File

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

View File

@ -1,184 +1,54 @@
package sushi.hardcore.droidfs.file_operations
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.content.ServiceConnection
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import androidx.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 kotlinx.coroutines.*
import sushi.hardcore.droidfs.ConstValues
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 encryptedVolume: EncryptedVolume
private lateinit var notificationManager: NotificationManagerCompat
private val tasks = HashMap<Int, Job>()
private var lastNotificationId = 0
inner class LocalBinder : Binder() {
fun getService(): FileOperationService = this@FileOperationService
}
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)
// Could have been more efficient with a LinkedHashMap but the JDK implementation doesn't allow
// to access the latest element in O(1) unless using reflection
service.notificationPermissionHelpers.removeAll { it.activity == activity }
}
})
activity.bindService(
Intent(activity, FileOperationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
fun setEncryptedVolume(volume: EncryptedVolume) {
encryptedVolume = volume
}
}
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
override fun onBind(p0: Intent?): IBinder {
return binder
}
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)
}
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 {
ContextCompat.startForegroundService(
this,
Intent(this, FileOperationService::class.java)
)
}
}
/**
* 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) {
private fun showNotification(message: Int, total: Int?): FileOperationNotification {
++lastNotificationId
if (!::notificationManager.isInitialized){
notificationManager = NotificationManagerCompat.from(this)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -191,219 +61,90 @@ class FileOperationService : Service() {
)
}
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
.setContentTitle(getString(message))
.setSmallIcon(R.mipmap.icon_launcher)
.setOngoing(true)
.addAction(NotificationCompat.Action(
R.drawable.icon_close,
getString(R.string.cancel),
PendingIntent.getBroadcast(
this,
0,
Intent(this, NotificationBroadcastReceiver::class.java).apply {
val bundle = Bundle()
bundle.putBinder("binder", LocalBinder())
bundle.putInt("notificationId", lastNotificationId)
putExtra("bundle", bundle)
action = ACTION_CANCEL
},
PendingIntent.FLAG_UPDATE_CURRENT
)
))
if (total != null) {
notificationBuilder
.setContentText("0/${task.total}")
.setProgress(task.total, 0, false)
.setContentText("0/$total")
.setProgress(total, 0, false)
} else {
notificationBuilder
.setContentText(getString(R.string.discovering_files))
.setProgress(0, 0, true)
}
showNotification(newTaskId, notificationBuilder.build())
notifications[newTaskId] = notificationBuilder
tasks[newTaskId] = task.start(newTaskId)
newTaskId++
notificationManager.notify(lastNotificationId, notificationBuilder.build())
return FileOperationNotification(notificationBuilder, lastNotificationId)
}
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
private fun updateNotificationProgress(notification: FileOperationNotification, progress: Int, total: Int){
notification.notificationBuilder
.setProgress(total, progress, false)
.setContentText("$progress/$total")
notificationManager.notify(taskId, notificationBuilder.build())
notificationManager.notify(notification.notificationId, notification.notificationBuilder.build())
}
fun cancelOperation(taskId: Int) {
tasks[taskId]?.cancel()
private fun cancelNotification(notification: FileOperationNotification){
notificationManager.cancel(notification.notificationId)
}
private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
fun cancelOperation(notificationId: Int){
tasks[notificationId]?.cancel()
}
/**
* 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())
}
}
}
open class TaskResult<T>(val cancelled: Boolean, val failedItem: T?)
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<T> {
tasks[notification.notificationId] = task
return try {
TaskResult(false, task.await())
} catch (e: CancellationException) {
TaskResult(true, null)
} finally {
cancelNotification(notification)
}
}
/**
* 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 {
private fun copyFile(srcPath: String, dstPath: String, remoteEncryptedVolume: EncryptedVolume = encryptedVolume): Boolean {
var success = true
val srcFileHandle = srcEncryptedVolume.openFileReadMode(srcPath)
val srcFileHandle = remoteEncryptedVolume.openFile(srcPath)
if (srcFileHandle != -1L) {
val dstFileHandle = encryptedVolume.openFileWriteMode(dstPath)
val dstFileHandle = encryptedVolume.openFile(dstPath)
if (dstFileHandle != -1L) {
var offset: Long = 0
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) {
val ioBuffer = ByteArray(ConstValues.IO_BUFF_SIZE)
var length: Int
while (remoteEncryptedVolume.read(srcFileHandle, ioBuffer, offset).also { length = it } > 0) {
val written = encryptedVolume.write(dstFileHandle, offset, ioBuffer, length).toLong()
if (written == length.toLong()) {
offset += written
} else {
success = false
break
}
}
encryptedVolume.truncate(dstPath, offset)
encryptedVolume.closeFile(dstFileHandle)
} else {
success = false
}
srcEncryptedVolume.closeFile(srcFileHandle)
remoteEncryptedVolume.closeFile(srcFileHandle)
} else {
success = false
}
@ -411,36 +152,41 @@ class FileOperationService : Service() {
}
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 ->
items: ArrayList<OperationFile>,
remoteEncryptedVolume: EncryptedVolume = encryptedVolume
): String? = coroutineScope {
val notification = showNotification(R.string.file_op_copy_msg, items.size)
val task = async {
var failedItem: String? = null
for (i in items.indices) {
yield()
if (items[i].isDirectory) {
if (!encryptedVolume.pathExists(items[i].dstPath!!)) {
if (!encryptedVolume.mkdir(items[i].dstPath!!)) {
for (i in 0 until items.size) {
withContext(Dispatchers.IO) {
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].srcPath, items[i].dstPath!!, remoteEncryptedVolume)) {
failedItem = items[i].srcPath
}
}
} else if (!copyFile(encryptedVolume, items[i].srcPath, items[i].dstPath!!, srcEncryptedVolume)) {
failedItem = items[i].srcPath
}
if (failedItem == null) {
updateNotificationProgress(taskId, i+1, items.size)
updateNotificationProgress(notification, i+1, items.size)
} else {
break
}
}
failedItem
}
// treat cancellation as success
waitForTask(notification, task).failedItem
}
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 ->
suspend fun moveElements(toMove: List<OperationFile>, toClean: List<String>): String? = coroutineScope {
val notification = showNotification(R.string.file_op_move_msg, toMove.size)
val task = async(Dispatchers.IO) {
val total = toMove.size+toClean.size
var failedItem: String? = null
for ((i, item) in toMove.withIndex()) {
@ -448,7 +194,7 @@ class FileOperationService : Service() {
failedItem = item.srcPath
break
} else {
updateNotificationProgress(taskId, i+1, total)
updateNotificationProgress(notification, i+1, total)
}
}
if (failedItem == null) {
@ -457,32 +203,34 @@ class FileOperationService : Service() {
failedItem = folderPath
break
} else {
updateNotificationProgress(taskId, toMove.size+i+1, total)
updateNotificationProgress(notification, toMove.size+i+1, total)
}
}
}
failedItem
}
// treat cancellation as success
waitForTask(notification, task).failedItem
}
private suspend fun importFilesFromUris(
encryptedVolume: EncryptedVolume,
dstPaths: List<String>,
uris: List<Uri>,
taskId: Int,
notification: FileOperationNotification,
): String? {
var failedIndex = -1
for (i in dstPaths.indices) {
yield()
try {
if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
withContext(Dispatchers.IO) {
try {
if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
failedIndex = i
}
} catch (e: FileNotFoundException) {
failedIndex = i
}
} catch (e: FileNotFoundException) {
failedIndex = i
}
if (failedIndex == -1) {
updateNotificationProgress(taskId, i+1, dstPaths.size)
updateNotificationProgress(notification, i+1, dstPaths.size)
} else {
return uris[failedIndex].toString()
}
@ -490,75 +238,94 @@ class FileOperationService : Service() {
return null
}
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)
suspend fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>): TaskResult<String?> = coroutineScope {
val notification = showNotification(R.string.file_op_import_msg, dstPaths.size)
val task = async {
importFilesFromUris(dstPaths, uris, notification)
}
waitForTask(notification, task)
}
/**
* 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 suspend fun recursiveMapDirectoryForImport(
private fun recursiveMapDirectoryForImport(
rootSrcDir: DocumentFile,
rootDstPath: String,
dstFiles: ArrayList<String>,
srcUris: ArrayList<Uri>,
dstDirs: ArrayList<String>,
) {
scope: CoroutineScope,
): Boolean {
dstDirs.add(rootDstPath)
for (child in rootSrcDir.listFiles()) {
yield()
if (!scope.isActive) {
return false
}
child.name?.let { name ->
val subPath = PathUtils.pathJoin(rootDstPath, name)
if (child.isDirectory) {
recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs)
} else if (child.isFile) {
if (!recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs, scope)) {
return false
}
}
else if (child.isFile) {
srcUris.add(child.uri)
dstFiles.add(subPath)
}
}
}
return true
}
class ImportDirectoryResult(val taskResult: TaskResult<out String?>, val uris: List<Uri>)
class ImportDirectoryResult(val taskResult: TaskResult<String?>, val uris: List<Uri>)
suspend fun importDirectory(
volumeId: Int,
rootDstPath: String,
rootSrcDir: DocumentFile,
): ImportDirectoryResult {
): ImportDirectoryResult = coroutineScope {
val notification = showNotification(R.string.file_op_import_msg, null)
val srcUris = arrayListOf<Uri>()
return ImportDirectoryResult(volumeTask(R.string.file_op_import_msg, null, volumeId) { taskId, encryptedVolume ->
val task = async {
var failedItem: String? = null
val dstFiles = arrayListOf<String>()
val dstDirs = arrayListOf<String>()
recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs)
// create destination folders so the new files can use them
for (dir in dstDirs) {
// if directory creation fails, check if it was already present
if (!encryptedVolume.mkdir(dir) && encryptedVolume.getAttr(dir)?.type != Stat.S_IFDIR) {
failedItem = dir
break
withContext(Dispatchers.IO) {
if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, this)) {
return@withContext
}
// create destination folders so the new files can use them
for (dir in dstDirs) {
if (!encryptedVolume.mkdir(dir)) {
failedItem = dir
break
}
}
}
if (failedItem == null) {
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, taskId)
failedItem = importFilesFromUris(dstFiles, srcUris, notification)
}
failedItem
}, srcUris)
}
ImportDirectoryResult(waitForTask(notification, task), srcUris)
}
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
return globalTask(R.string.file_op_wiping_msg, uris.size, { taskId ->
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): String? = coroutineScope {
val notification = showNotification(R.string.file_op_wiping_msg, uris.size)
val task = async {
var errorMsg: String? = null
for (i in uris.indices) {
yield()
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
withContext(Dispatchers.IO) {
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
}
if (errorMsg == null) {
updateNotificationProgress(taskId, i+1, uris.size)
updateNotificationProgress(notification, i+1, uris.size)
} else {
break
}
@ -567,10 +334,12 @@ class FileOperationService : Service() {
rootFile?.delete()
}
errorMsg
})
}
// treat cancellation as success
waitForTask(notification, task).failedItem
}
private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
private fun exportFileInto(srcPath: String, treeDocumentFile: DocumentFile): Boolean {
val outputStream = treeDocumentFile.createFile("*/*", File(srcPath).name)?.uri?.let {
contentResolver.openOutputStream(it)
}
@ -581,20 +350,25 @@ class FileOperationService : Service() {
}
}
private suspend fun recursiveExportDirectory(
encryptedVolume: EncryptedVolume,
private fun recursiveExportDirectory(
plain_directory_path: String,
treeDocumentFile: DocumentFile,
scope: CoroutineScope
): String? {
treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree ->
val explorerElements = encryptedVolume.readDir(plain_directory_path) ?: return null
for (e in explorerElements) {
yield()
if (!scope.isActive) {
return null
}
val fullPath = PathUtils.pathJoin(plain_directory_path, e.name)
if (e.isDirectory) {
recursiveExportDirectory(encryptedVolume, fullPath, childTree)?.let { return it }
} else if (!exportFileInto(encryptedVolume, fullPath, childTree)) {
return fullPath
val failedItem = recursiveExportDirectory(fullPath, childTree, scope)
failedItem?.let { return it }
} else {
if (!exportFileInto(fullPath, childTree)){
return fullPath
}
}
}
return null
@ -602,94 +376,62 @@ class FileOperationService : Service() {
return treeDocumentFile.name
}
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 ->
suspend fun exportFiles(uri: Uri, items: List<ExplorerElement>): TaskResult<String?> = coroutineScope {
val notification = showNotification(R.string.file_op_export_msg, items.size)
val task = async {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
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
withContext(Dispatchers.IO) {
failedItem = if (items[i].isDirectory) {
recursiveExportDirectory(items[i].fullPath, treeDocumentFile, this)
} else {
items[i].fullPath
if (exportFileInto(items[i].fullPath, treeDocumentFile)) null else items[i].fullPath
}
}
if (failedItem == null) {
updateNotificationProgress(taskId, i+1, items.size)
updateNotificationProgress(notification, i+1, items.size)
} else {
break
}
}
failedItem
}
waitForTask(notification, task)
}
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
}
}
private fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int {
if (!scope.isActive) {
return 0
}
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)
count += recursiveCountChildElements(child, scope)
}
}
return count
}
private suspend fun recursiveCopyVolume(
internal class ObjRef<T>(var value: T)
private fun recursiveCopyVolume(
src: DocumentFile,
dst: DocumentFile,
dstRootDirectory: ObjRef<DocumentFile?>?,
taskId: Int,
notification: FileOperationNotification,
total: Int,
scope: CoroutineScope,
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 (!scope.isActive) {
return null
}
if (child.isFile) {
val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child
val outputStream = contentResolver.openOutputStream(dstFile.uri)
@ -700,25 +442,29 @@ class FileOperationService : Service() {
inputStream.close()
if (written != child.length()) return child
} else {
recursiveCopyVolume(child, dstDir, null, taskId, total, progress)?.let { return it }
recursiveCopyVolume(child, dstDir, null, notification, total, scope, progress)?.let { return it }
}
progress.value++
updateNotificationProgress(taskId, progress.value, total)
updateNotificationProgress(notification, progress.value, total)
}
return null
}
class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
class CopyVolumeResult(val taskResult: TaskResult<DocumentFile?>, val dstRootDirectory: DocumentFile?)
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult = coroutineScope {
val notification = showNotification(R.string.copy_volume_notification, null)
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)
val task = async(Dispatchers.IO) {
val total = recursiveCountChildElements(src, this)
if (isActive) {
updateNotificationProgress(notification, 0, total)
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this)
} else {
null
}
}
// treat cancellation as success
CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
}
}

View File

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

View File

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

View File

@ -1,11 +1,8 @@
package sushi.hardcore.droidfs.file_viewers
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import com.google.android.exoplayer2.ExoPlayer
import sushi.hardcore.droidfs.databinding.ActivityAudioPlayerBinding
@OptIn(UnstableApi::class)
class AudioPlayer: MediaPlayer(){
private lateinit var binding: ActivityAudioPlayerBinding

View File

@ -1,37 +1,27 @@
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 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.filesystems.EncryptedVolume
import kotlin.math.ceil
import kotlin.math.min
@OptIn(UnstableApi::class)
class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, private val filePath: String):
DataSource {
class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, private val filePath: String): DataSource {
private var fileHandle = -1L
private var fileSize: Long = -1
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
fileHandle = encryptedVolume.openFile(filePath)
fileSize = encryptedVolume.getAttr(filePath)!!.size
return fileSize
}
override fun getUri(): Uri {
return Constants.FAKE_URI
return ConstValues.FAKE_URI
}
override fun close() {
@ -43,22 +33,23 @@ class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, pr
}
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()
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 = encryptedVolume.read(fileHandle, tmpBuff, fileOffset)
System.arraycopy(tmpBuff, 0, buffer, offset+totalRead, read)
fileOffset += read
totalRead += read
}
return totalRead
}
class Factory(private val encryptedVolume: EncryptedVolume, private val filePath: String): DataSource.Factory {

View File

@ -1,30 +1,16 @@
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.ConstValues
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.finishOnClose
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
abstract class FileViewerActivity: BaseActivity() {
@ -33,157 +19,135 @@ abstract class FileViewerActivity: BaseActivity() {
private lateinit var originalParentPath: String
private lateinit var windowInsetsController: WindowInsetsControllerCompat
private var windowTypeMask = 0
protected val playlist = mutableListOf<ExplorerElement>()
private val playlistMutex = Mutex()
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
private val legacyMod by lazy {
sharedPrefs.getBoolean("legacyMod", false)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
filePath = intent.getStringExtra("path")!!
originalParentPath = PathUtils.getParentPath(filePath)
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
intent.getIntExtra("volumeId", -1)
)!!
finishOnClose(encryptedVolume)
encryptedVolume = intent.getParcelableExtra("volume")!!
usf_keep_open = sharedPrefs.getBoolean("usf_keep_open", false)
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
windowTypeMask = typeMask
}
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
hideSystemUi()
viewFile()
}
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) {
if (legacyMod) {
@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
View.SYSTEM_UI_FLAG_FULLSCREEN
} 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
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
}
}
abstract fun getFileType(): String
abstract fun viewFile()
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)
override fun onUserInteraction() {
super.onUserInteraction()
if (windowTypeMask and WindowInsetsCompat.Type.statusBars() == 0) {
hideSystemUi()
}
}
protected fun loadWholeFile(path: String, fileSize: Long? = null): ByteArray? {
val result = encryptedVolume.loadWholeFile(path, size = fileSize)
if (result.second != 0) {
val dialog = CustomAlertDialogBuilder(this, themeValue)
.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)
else -> dialog.setMessage(R.string.read_file_failed)
}
dialog.show()
}
return result.first
}
protected fun createPlaylist() {
if (!wasMapped){
encryptedVolume.recursiveMapFiles(originalParentPath)?.let { elements ->
for (e in elements) {
if (e.isRegularFile) {
if (ConstValues.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) {
mappedPlaylist.add(e)
}
dialog.show()
}
}
}
}
}
protected suspend fun createPlaylist() {
playlistMutex.withLock {
if (currentPlaylistIndex != -1) {
// playlist already initialized
return
}
withContext(Dispatchers.IO) {
if (sharedPrefs.getBoolean("map_folders", true)) {
encryptedVolume.recursiveMapFiles(originalParentPath)
} else {
encryptedVolume.readDir(originalParentPath)
}?.filterTo(playlist) { e ->
e.isRegularFile && (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath)
val sortOrder = intent.getStringExtra("sortOrder") ?: "name"
ExplorerElement.sortBy(sortOrder, foldersFirst, mappedPlaylist)
//find current index
for ((i, e) in mappedPlaylist.withIndex()){
if (filePath == e.fullPath){
currentPlaylistIndex = i
break
}
val sortOrder = intent.getStringExtra("sortOrder") ?: "name"
val foldersFirst = sharedPrefs.getBoolean("folders_first", true)
ExplorerElement.sortBy(sortOrder, foldersFirst, playlist)
currentPlaylistIndex = playlist.indexOfFirst { it.fullPath == filePath }
}
wasMapped = true
}
}
private fun updateCurrentItem() {
filePath = playlist[currentPlaylistIndex].fullPath
}
protected suspend fun playlistNext(forward: Boolean) {
protected fun playlistNext(forward: Boolean) {
createPlaylist()
currentPlaylistIndex = if (forward) {
(currentPlaylistIndex + 1).mod(playlist.size)
(currentPlaylistIndex+1)%mappedPlaylist.size
} else {
(currentPlaylistIndex - 1).mod(playlist.size)
var x = (currentPlaylistIndex-1)%mappedPlaylist.size
if (x < 0) {
x += mappedPlaylist.size
}
x
}
updateCurrentItem()
filePath = mappedPlaylist[currentPlaylistIndex].fullPath
}
protected suspend fun deleteCurrentFile(): Boolean {
createPlaylist() // ensure we know the current position in the playlist
return if (encryptedVolume.deleteFile(filePath)) {
playlist.removeAt(currentPlaylistIndex)
if (playlist.size != 0) {
if (currentPlaylistIndex == playlist.size) {
// deleted the last element of the playlist, go back to the first
currentPlaylistIndex = 0
}
updateCurrentItem()
}
true
} else {
false
}
protected fun refreshPlaylist() {
mappedPlaylist.clear()
wasMapped = false
createPlaylist()
}
protected fun goBackToExplorer() {
isFinishingIntentionally = true
finish()
}
override fun onDestroy() {
super.onDestroy()
if (!isFinishingIntentionally) {
encryptedVolume.close()
RestrictedFileProvider.wipeAll(this)
}
}
override fun onPause() {
super.onPause()
if (!usf_keep_open) {
finish()
}
}
override fun onBackPressed() {
super.onBackPressed()
isFinishingIntentionally = true
}
}

View File

@ -1,7 +1,9 @@
package sushi.hardcore.droidfs.file_viewers
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.graphics.drawable.Drawable
import android.os.Handler
@ -9,16 +11,12 @@ 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 androidx.lifecycle.lifecycleScope
import androidx.exifinterface.media.ExifInterface
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 kotlinx.coroutines.launch
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.ConstValues
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.databinding.ActivityImageViewerBinding
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
@ -35,23 +33,19 @@ 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 val imageViewModel: ImageViewModel by viewModels()
private var bitmap: Bitmap? = null
private var requestBuilder: RequestBuilder<Drawable>? = null
private var x1 = 0F
private var x2 = 0F
private var slideshowActive = false
private var originalOrientation: Float = 0f
private var rotationAngle: Float = 0F
private var orientationTransformation: OrientationTransformation? = null
private val hideUI = Runnable {
binding.actionButtons.visibility = View.GONE
binding.topBar.visibility = View.GONE
hideSystemUi()
}
private val slideshowNext = Runnable {
if (slideshowActive){
@ -69,8 +63,6 @@ class ImageViewer: FileViewerActivity() {
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?) {
@ -78,7 +70,6 @@ class ImageViewer: FileViewerActivity() {
if (binding.actionButtons.visibility == View.GONE) {
binding.actionButtons.visibility = View.VISIBLE
binding.topBar.visibility = View.VISIBLE
showPartialSystemUi()
handler.postDelayed(hideUI, hideDelay)
} else {
hideUI.run()
@ -103,25 +94,26 @@ class ImageViewer: FileViewerActivity() {
}
})
binding.imageDelete.setOnClickListener {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.keepFullScreen()
.setTitle(R.string.warning)
.setPositiveButton(R.string.ok) { _, _ ->
lifecycleScope.launch {
if (deleteCurrentFile()) {
if (playlist.size == 0) { // no more image left
goBackToExplorer()
} else {
loadImage(true)
}
createPlaylist() //be sure the playlist is created before deleting if there is only one image
if (encryptedVolume.deleteFile(filePath)) {
playlistNext(true)
refreshPlaylist()
if (mappedPlaylist.size == 0) { //deleted all images of the playlist
goBackToExplorer()
} else {
CustomAlertDialogBuilder(this@ImageViewer, theme)
.keepFullScreen()
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, fileName))
.setPositiveButton(R.string.ok, null)
.show()
loadImage()
}
} else {
CustomAlertDialogBuilder(this, themeValue)
.keepFullScreen()
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, fileName))
.setPositiveButton(R.string.ok, null)
.show()
}
}
.setNegativeButton(R.string.cancel, null)
@ -131,7 +123,7 @@ class ImageViewer: FileViewerActivity() {
binding.imageButtonSlideshow.setOnClickListener {
if (!slideshowActive){
slideshowActive = true
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
handler.postDelayed(slideshowNext, ConstValues.SLIDESHOW_DELAY)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
hideUI.run()
Toast.makeText(this, R.string.slideshow_started, Toast.LENGTH_SHORT).show()
@ -151,39 +143,64 @@ class ImageViewer: FileViewerActivity() {
swipeImage(-1F)
}
}
binding.imageRotateRight.setOnClickListener { onClickRotate(90f) }
binding.imageRotateLeft.setOnClickListener { onClickRotate(-90f) }
onBackPressedDispatcher.addCallback(this) {
if (slideshowActive) {
stopSlideshow()
} else {
askSaveRotation {
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
binding.imageRotateRight.setOnClickListener {
rotationAngle += 90
rotateImage()
}
loadImage(false)
binding.imageRotateLeft.setOnClickListener {
rotationAngle -= 90
rotateImage()
}
loadImage()
handler.postDelayed(hideUI, hideDelay)
}
private fun loadImage(newImage: Boolean) {
fileName = File(filePath).name
binding.textFilename.text = fileName
if (newImage || imageViewModel.imageBytes == null) {
loadWholeFile(filePath) {
imageViewModel.imageBytes = it
private fun loadImage(){
bitmap = null
requestBuilder = null
loadWholeFile(filePath)?.let {
val displayWithGlide = if (it.size < 5_000_000) {
true
} else {
bitmap = BitmapFactory.decodeByteArray(it, 0, it.size)
if (bitmap == null) {
true
} else {
val orientation = ExifInterface(ByteArrayInputStream(it)).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
originalOrientation = when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
val displayMetrics = Resources.getSystem().displayMetrics
if (displayMetrics.widthPixels < bitmap!!.width || displayMetrics.heightPixels < bitmap!!.height) {
val newWidth: Int
val newHeight: Int
if (displayMetrics.widthPixels > displayMetrics.heightPixels) {
newWidth = displayMetrics.widthPixels
newHeight = bitmap!!.height*displayMetrics.widthPixels/bitmap!!.width
} else {
newHeight = displayMetrics.heightPixels
newWidth = bitmap!!.width*displayMetrics.heightPixels/bitmap!!.height
}
bitmap = Bitmap.createScaledBitmap(bitmap!!, newWidth, newHeight, false)
}
Glide.with(this).load(bitmap).transform(OrientationTransformation(originalOrientation)).into(binding.imageViewer)
false
}
}
if (displayWithGlide) {
originalOrientation = 0f
requestBuilder = Glide.with(this).load(it)
requestBuilder?.into(binding.imageViewer)
imageViewModel.rotationAngle = 0f
}
} else {
requestBuilder = Glide.with(this).load(imageViewModel.imageBytes)
if (imageViewModel.rotationAngle.mod(360f) != 0f) {
rotateImage()
} else {
requestBuilder?.into(binding.imageViewer)
}
fileName = File(filePath).name
binding.textFilename.text = fileName
rotationAngle = originalOrientation
}
}
@ -193,22 +210,14 @@ 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) {
lifecycleScope.launch {
playlistNext(deltaX < 0)
loadImage(true)
if (slideshowActive) {
if (!slideshowSwipe) { // reset slideshow delay if user swipes
handler.removeCallbacks(slideshowNext)
}
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false){
playlistNext(deltaX < 0)
loadImage()
if (slideshowActive){
if (!slideshowSwipe) { //reset slideshow delay if user swipes
handler.removeCallbacks(slideshowNext)
}
handler.postDelayed(slideshowNext, ConstValues.SLIDESHOW_DELAY)
}
}
@ -218,6 +227,14 @@ 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() {
lateinit var bitmap: Bitmap
@ -235,14 +252,15 @@ class ImageViewer: FileViewerActivity() {
}
}
private fun rotateImage() {
orientationTransformation = OrientationTransformation(imageViewModel.rotationAngle)
requestBuilder?.transform(orientationTransformation)?.into(binding.imageViewer)
private fun rotateImage(){
binding.imageViewer.restoreZoomNormal()
orientationTransformation = OrientationTransformation(rotationAngle)
(requestBuilder ?: Glide.with(this).load(bitmap)).transform(orientationTransformation).into(binding.imageViewer)
}
private fun askSaveRotation(callback: () -> Unit){
if (imageViewModel.rotationAngle.mod(360f) != 0f && !slideshowActive) {
CustomAlertDialogBuilder(this, theme)
if (rotationAngle.mod(360f) != originalOrientation && !slideshowActive) {
CustomAlertDialogBuilder(this, themeValue)
.keepFullScreen()
.setTitle(R.string.warning)
.setMessage(R.string.ask_save_img_rotated)
@ -255,13 +273,13 @@ class ImageViewer: FileViewerActivity() {
Bitmap.CompressFormat.PNG
} else {
Bitmap.CompressFormat.JPEG
}, 90, outputStream) == true
}, 100, outputStream) == true
){
if (encryptedVolume.importFile(ByteArrayInputStream(outputStream.toByteArray()), filePath)) {
Toast.makeText(this, R.string.image_saved_successfully, Toast.LENGTH_SHORT).show()
callback()
} else {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.keepFullScreen()
.setTitle(R.string.error)
.setMessage(R.string.file_write_failed)
@ -269,7 +287,7 @@ class ImageViewer: FileViewerActivity() {
.show()
}
} else {
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.keepFullScreen()
.setTitle(R.string.error)
.setMessage(R.string.bitmap_compress_failed)

View File

@ -1,24 +1,16 @@
package sushi.hardcore.droidfs.file_viewers
import android.view.WindowManager
import androidx.annotation.OptIn
import androidx.lifecycle.lifecycleScope
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 kotlinx.coroutines.launch
import sushi.hardcore.droidfs.Constants
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 com.google.android.exoplayer2.video.VideoSize
import sushi.hardcore.droidfs.ConstValues
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File
@OptIn(UnstableApi::class)
abstract class MediaPlayer: FileViewerActivity() {
private lateinit var player: ExoPlayer
@ -35,29 +27,25 @@ abstract class MediaPlayer: FileViewerActivity() {
private fun createMediaSource(filePath: String): MediaSource {
val dataSourceFactory = EncryptedVolumeDataSource.Factory(encryptedVolume, filePath)
return ProgressiveMediaSource.Factory(dataSourceFactory, DefaultExtractorsFactory())
.createMediaSource(MediaItem.fromUri(Constants.FAKE_URI))
.createMediaSource(MediaItem.fromUri(ConstValues.FAKE_URI))
}
private fun initializePlayer(){
player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
bindPlayer(player)
player.addMediaSource(createMediaSource(filePath))
lifecycleScope.launch {
createPlaylist()
playlist.forEachIndexed { index, e ->
if (index != currentPlaylistIndex) {
player.addMediaSource(index, createMediaSource(e.fullPath))
}
}
createPlaylist()
for (e in mappedPlaylist) {
player.addMediaSource(createMediaSource(e.fullPath))
}
player.repeatMode = Player.REPEAT_MODE_ALL
player.seekToDefaultPosition(currentPlaylistIndex)
player.playWhenReady = true
player.addListener(object : Player.Listener{
override fun onVideoSizeChanged(videoSize: VideoSize) {
onVideoSizeChanged(videoSize.width, videoSize.height)
}
override fun onPlayerError(error: PlaybackException) {
CustomAlertDialogBuilder(this@MediaPlayer, theme)
CustomAlertDialogBuilder(this@MediaPlayer, themeValue)
.setTitle(R.string.error)
.setMessage(getString(R.string.playing_failed, error.errorCodeName))
.setCancelable(false)
@ -71,13 +59,10 @@ abstract class MediaPlayer: FileViewerActivity() {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (player.repeatMode != Player.REPEAT_MODE_ONE && currentPlaylistIndex != -1) {
lifecycleScope.launch {
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % player.mediaItemCount)
refreshFileName()
}
override fun onPositionDiscontinuity(reason: Int) {
if (player.currentMediaItemIndex != currentPlaylistIndex) {
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex+1) % mappedPlaylist.size)
refreshFileName()
}
}
})

View File

@ -7,11 +7,12 @@ import java.io.ByteArrayInputStream
import java.io.File
class PdfViewer: FileViewerActivity() {
init {
applyCustomTheme = false
}
private lateinit var pdfViewer: PdfViewer
override fun hideSystemUi() {
//don't hide system ui
}
override fun getFileType(): String {
return "pdf"
}
@ -21,12 +22,12 @@ class PdfViewer: FileViewerActivity() {
val fileName = File(filePath).name
title = fileName
val fileSize = encryptedVolume.getAttr(filePath)?.size
loadWholeFile(filePath, fileSize) {
loadWholeFile(filePath, fileSize)?.let {
pdfViewer.loadPdf(ByteArrayInputStream(it), fileName, fileSize)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
pdfViewer.onCreateOptionMenu(menu)
return super.onCreateOptionsMenu(menu)
}
@ -36,12 +37,7 @@ class PdfViewer: FileViewerActivity() {
pdfViewer.onResume()
}
override fun onDestroy() {
super.onDestroy()
pdfViewer.onDestroy()
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
return pdfViewer.onPrepareOptionsMenu(menu)
}

View File

@ -7,9 +7,10 @@ import android.view.Menu
import android.view.MenuItem
import android.widget.EditText
import android.widget.Toast
import androidx.activity.addCallback
import sushi.hardcore.droidfs.ConstValues
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.ByteArrayInputStream
import java.io.File
class TextEditor: FileViewerActivity() {
@ -17,23 +18,21 @@ class TextEditor: FileViewerActivity() {
private lateinit var editor: EditText
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() {
fileName = File(filePath).name
title = fileName
supportActionBar?.setDisplayHomeAsUpEnabled(true)
loadWholeFile(filePath) {
loadWholeFile(filePath)?.let {
fileName = File(filePath).name
try {
loadLayout(String(it))
onBackPressedDispatcher.addCallback(this) {
checkSaveAndExit()
}
} catch (e: OutOfMemoryError){
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.error)
.setMessage(R.string.outofmemoryerror_msg)
.setCancelable(false)
@ -48,6 +47,8 @@ class TextEditor: FileViewerActivity() {
} else {
setContentView(R.layout.activity_text_editor)
}
title = fileName
supportActionBar?.setDisplayHomeAsUpEnabled(true)
editor = findViewById(R.id.text_editor)
editor.setText(fileContent)
editor.addTextChangedListener(object: TextWatcher {
@ -67,14 +68,25 @@ class TextEditor: FileViewerActivity() {
private fun save(): Boolean{
var success = false
val content = editor.text.toString().toByteArray()
val fileHandle = encryptedVolume.openFileWriteMode(filePath)
val fileHandle = encryptedVolume.openFile(filePath)
if (fileHandle != -1L) {
val buff = ByteArrayInputStream(content)
var offset: Long = 0
while (offset < content.size && encryptedVolume.write(fileHandle, offset, content, offset, content.size.toLong()).also { offset += it } > 0) {}
val ioBuffer = ByteArray(ConstValues.IO_BUFF_SIZE)
var length: Int
while (buff.read(ioBuffer).also { length = it } > 0) {
val written = encryptedVolume.write(fileHandle, offset, ioBuffer, length).toLong()
if (written == length.toLong()) {
offset += written
} else {
break
}
}
if (offset == content.size.toLong()){
success = encryptedVolume.truncate(filePath, offset)
}
encryptedVolume.closeFile(fileHandle)
buff.close()
}
if (success){
Toast.makeText(this, getString(R.string.file_saved), Toast.LENGTH_SHORT).show()
@ -86,7 +98,7 @@ class TextEditor: FileViewerActivity() {
private fun checkSaveAndExit(){
if (changedSinceLastSave){
CustomAlertDialogBuilder(this, theme)
CustomAlertDialogBuilder(this, themeValue)
.setTitle(R.string.warning)
.setMessage(R.string.ask_save)
.setPositiveButton(R.string.save) { _, _ ->
@ -127,4 +139,8 @@ class TextEditor: FileViewerActivity() {
}
return true
}
override fun onBackPressed() {
checkSaveAndExit()
}
}

View File

@ -2,9 +2,7 @@ package sushi.hardcore.droidfs.file_viewers
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.view.View
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.google.android.exoplayer2.ExoPlayer
import sushi.hardcore.droidfs.databinding.ActivityVideoPlayerBinding
class VideoPlayer: MediaPlayer() {
@ -17,16 +15,10 @@ 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.videoPlayer.setControllerVisibilityListener { visibility ->
binding.topBar.visibility = visibility
if (visibility == View.VISIBLE) {
showPartialSystemUi()
} else {
hideSystemUi()
}
})
}
binding.rotateButton.setOnClickListener {
requestedOrientation =
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {

View File

@ -1,9 +1,8 @@
package sushi.hardcore.droidfs.filesystems
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R
import android.os.Parcel
import sushi.hardcore.droidfs.ConstValues
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() {
@ -17,25 +16,14 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
private external fun nativeInit(
baseDir: String,
localStateDir: String,
password: ByteArray?,
givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>?,
password: ByteArray,
createBaseDir: Boolean,
cipher: String?,
errorCode: ObjRef<Int?>,
cipher: String?
): 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 nativeRead(fusePtr: Long, fileHandle: Long, buffer: ByteArray, offset: Long): Int
private external fun nativeWrite(fusePtr: Long, fileHandle: Long, offset: Long, buffer: ByteArray, size: Int): 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
@ -48,63 +36,35 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
private external fun nativeIsClosed(fusePtr: Long): Boolean
fun getLocalStateDir(filesDir: String): String {
return PathUtils.pathJoin(filesDir, Constants.CRYFS_LOCAL_STATE_DIR)
return PathUtils.pathJoin(filesDir, ConstValues.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
}
private fun init(baseDir: String, localStateDir: String, password: ByteArray, createBaseDir: Boolean, cipher: String?): CryfsVolume? {
val fusePtr = nativeInit(baseDir, localStateDir, password, createBaseDir, cipher)
return if (fusePtr == 0L) {
null
} else {
result.volume = CryfsVolume(fusePtr)
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 create(baseDir: String, localStateDir: String, password: ByteArray, cipher: String?): Boolean {
return init(baseDir, localStateDir, password, true, cipher)?.also { it.close() } != null
}
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)
fun init(baseDir: String, localStateDir: String, password: ByteArray): CryfsVolume? {
return init(baseDir, localStateDir, password, false, null)
}
}
override fun openFileReadMode(path: String): Long {
return nativeOpen(fusePtr, path, 0)
constructor(parcel: Parcel) : this(parcel.readLong())
override fun writeToParcel(parcel: Parcel, flags: Int) = with(parcel) {
writeByte(CRYFS_VOLUME_TYPE)
writeLong(fusePtr)
}
override fun openFileWriteMode(path: String): Long {
override fun openFile(path: String): Long {
val fileHandle = nativeOpen(fusePtr, path, 0)
return if (fileHandle == -1L) {
nativeCreate(fusePtr, path, 0)
@ -113,12 +73,12 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
}
}
override fun read(fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int {
return nativeRead(fusePtr, fileHandle, fileOffset, buffer, dstOffset, length)
override fun read(fileHandle: Long, buffer: ByteArray, offset: Long): Int {
return nativeRead(fusePtr, fileHandle, buffer, offset)
}
override fun write(fileHandle: Long, fileOffset: Long, buffer: ByteArray, srcOffset: Long, length: Long): Int {
return nativeWrite(fusePtr, fileHandle, fileOffset, buffer, srcOffset, length)
override fun write(fileHandle: Long, offset: Long, buffer: ByteArray, size: Int): Int {
return nativeWrite(fusePtr, fileHandle, offset, buffer, size)
}
override fun truncate(path: String, size: Long): Boolean {

View File

@ -2,47 +2,34 @@ package sushi.hardcore.droidfs.filesystems
import android.content.Context
import android.net.Uri
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.VolumeData
import android.os.Parcel
import android.os.Parcelable
import sushi.hardcore.droidfs.ConstValues
import sushi.hardcore.droidfs.SavedVolume
import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.Observable
import sushi.hardcore.droidfs.util.PathUtils
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)
}
}
abstract class EncryptedVolume: Parcelable {
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
*/
@JvmField
val CREATOR = object : Parcelable.Creator<EncryptedVolume> {
override fun createFromParcel(parcel: Parcel): EncryptedVolume {
return when (parcel.readByte()) {
GOCRYPTFS_VOLUME_TYPE -> GocryptfsVolume(parcel)
CRYFS_VOLUME_TYPE -> CryfsVolume(parcel)
else -> throw invalidVolumeType()
}
}
override fun newArray(size: Int) = arrayOfNulls<EncryptedVolume>(size)
}
fun getVolumeType(path: String): Byte {
return if (File(path, GocryptfsVolume.CONFIG_FILE_NAME).isFile) {
GOCRYPTFS_VOLUME_TYPE
@ -53,26 +40,13 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
}
}
fun init(
volume: VolumeData,
filesDir: String,
password: ByteArray?,
givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>?
): InitResult {
fun init(volume: SavedVolume, filesDir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): EncryptedVolume? {
return when (volume.type) {
GOCRYPTFS_VOLUME_TYPE -> {
GocryptfsVolume.init(
volume.getFullPath(filesDir),
password,
givenHash,
returnedHash?.apply {
value = ByteArray(GocryptfsVolume.KeyLen)
}?.value
)
GocryptfsVolume.init(volume.getFullPath(filesDir), password, givenHash, returnedHash)
}
CRYFS_VOLUME_TYPE -> {
CryfsVolume.init(volume.getFullPath(filesDir), CryfsVolume.getLocalStateDir(filesDir), password, givenHash, returnedHash)
CryfsVolume.init(volume.getFullPath(filesDir), CryfsVolume.getLocalStateDir(filesDir), password!!)
}
else -> throw invalidVolumeType()
}
@ -83,12 +57,12 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
}
}
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
override fun describeContents() = 0
abstract fun openFile(path: String): Long
abstract fun read(fileHandle: Long, buffer: ByteArray, offset: Long): Int
abstract fun write(fileHandle: Long, offset: Long, buffer: ByteArray, size: Int): 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>?
@ -96,23 +70,18 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
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 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)
val ioBuffer = ByteArray(ConstValues.IO_BUFF_SIZE)
var length: Int
while (read(fileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it } > 0) {
while (read(fileHandle, ioBuffer, offset).also { length = it } > 0) {
os.write(ioBuffer, 0, length)
offset += length.toLong()
}
@ -122,7 +91,7 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
fun exportFile(src_path: String, os: OutputStream): Boolean {
var success = false
val srcfileHandle = openFileReadMode(src_path)
val srcfileHandle = openFile(src_path)
if (srcfileHandle != -1L) {
success = exportFile(srcfileHandle, os)
closeFile(srcfileHandle)
@ -143,22 +112,22 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
}
fun importFile(inputStream: InputStream, dst_path: String): Boolean {
val dstfileHandle = openFileWriteMode(dst_path)
val dstfileHandle = openFile(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) {
val ioBuffer = ByteArray(ConstValues.IO_BUFF_SIZE)
var length: Int
while (inputStream.read(ioBuffer).also { length = it } > 0) {
val written = write(dstfileHandle, offset, ioBuffer, length).toLong()
if (written == length.toLong()) {
offset += written
} else {
inputStream.close()
success = false
break
}
}
truncate(dst_path, offset)
closeFile(dstfileHandle)
inputStream.close()
return success
@ -184,12 +153,17 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
}
try {
val fileBuff = ByteArray(fileSize.toInt())
val fileHandle = openFileReadMode(fullPath)
val fileHandle = openFile(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) {}
val ioBuffer = ByteArray(ConstValues.IO_BUFF_SIZE)
var length: Int
while (read(fileHandle, ioBuffer, offset).also { length = it } > 0) {
System.arraycopy(ioBuffer, 0, fileBuff, offset.toInt(), length)
offset += length.toLong()
}
closeFile(fileHandle)
if (offset == fileBuff.size.toLong()) {
Pair(fileBuff, 0)
@ -216,4 +190,25 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
}
return result
}
fun recursiveRemoveDirectory(path: String): String? {
readDir(path)?.let { elements ->
for (e in elements) {
val fullPath = PathUtils.pathJoin(path, e.name)
if (e.isDirectory) {
val result = recursiveRemoveDirectory(fullPath)
result?.let { return it }
} else {
if (!deleteFile(fullPath)) {
return fullPath
}
}
}
}
return if (!rmdir(path)) {
path
} else {
null
}
}
}

View File

@ -1,10 +1,7 @@
package sushi.hardcore.droidfs.filesystems
import android.util.Log
import sushi.hardcore.droidfs.R
import android.os.Parcel
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)
@ -12,8 +9,8 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
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_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, 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
@ -24,74 +21,19 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
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 ScryptDefaultLogN = 16
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
external fun createVolume(root_cipher_dir: String, password: ByteArray, plainTextNames: Boolean, xchacha: Int, logN: Int, creator: String, returnedHash: ByteArray?): Boolean
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
external fun changePassword(root_cipher_dir: String, old_password: CharArray?, givenHash: ByteArray?, new_password: CharArray, 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 {
fun init(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): GocryptfsVolume? {
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
}
return if (sessionId == -1) {
null
} else {
result.volume = GocryptfsVolume(sessionId)
GocryptfsVolume(sessionId)
}
return result.build()
}
init {
@ -99,16 +41,14 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
}
}
override fun openFileReadMode(path: String): Long {
return native_open_read_mode(sessionID, path).toLong()
constructor(parcel: Parcel) : this(parcel.readInt())
override fun openFile(path: String): Long {
return native_open_write_mode(sessionID, path, 0).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 read(fileHandle: Long, buffer: ByteArray, offset: Long): Int {
return native_read_file(sessionID, fileHandle.toInt(), offset, buffer)
}
override fun readDir(path: String): MutableList<ExplorerElement>? {
@ -119,6 +59,11 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
return native_get_attr(sessionID, path)
}
override fun writeToParcel(parcel: Parcel, flags: Int) = with(parcel) {
writeByte(GOCRYPTFS_VOLUME_TYPE)
writeInt(sessionID)
}
override fun close() {
native_close(sessionID)
}
@ -128,7 +73,7 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
}
override fun mkdir(path: String): Boolean {
return native_mkdir(sessionID, path, 448) // 0700
return native_mkdir(sessionID, path, 0)
}
override fun rmdir(path: String): Boolean {
@ -140,8 +85,8 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
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 write(fileHandle: Long, offset: Long, buffer: ByteArray, size: Int): Int {
return native_write_file(sessionID, fileHandle.toInt(), offset, buffer, size)
}
override fun truncate(path: String, size: Long): Boolean {

View File

@ -1,17 +1,14 @@
package sushi.hardcore.droidfs.filesystems
class Stat(mode: Int, var size: Long, val mTime: Long) {
class Stat(val type: 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
const val PARENT_FOLDER_TYPE = -1
fun parentFolderStat(): Stat {
return Stat(PARENT_FOLDER_TYPE, -1, -1)
}
}
val type = mode and S_IFMT
}

View File

@ -1,110 +0,0 @@
package sushi.hardcore.droidfs.util
import android.Manifest
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import kotlin.reflect.KProperty
object AndroidUtils {
fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean {
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
@Suppress("DEPRECATION")
for (service in manager.getRunningServices(Int.MAX_VALUE)) {
if (serviceClass.name == service.service.className) {
return true
}
}
return false
}
/**
* A [Manifest.permission.POST_NOTIFICATIONS] permission helper.
*
* Must be initialized before [Activity.onCreate] finishes.
*/
class NotificationPermissionHelper<out A: AppCompatActivity>(val activity: A) {
private var listener: ((Boolean) -> Unit)? = null
private val launcher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
listener?.invoke(granted)
listener = null
}
/**
* Ask for notification permission if required and run the provided callback.
*
* The callback is run as soon as the user dismisses the permission dialog,
* no matter if the permission has been granted or not.
*
* If this function is called again before the user answered the dialog from the
* previous call, the previous callback won't be triggered.
*
* @param onDialogDismiss argument set to `true` if the permission is granted or
* not required, `false` otherwise
*/
fun askAndRun(onDialogDismiss: (Boolean) -> Unit) {
assert(listener == null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
listener = onDialogDismiss
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
}
onDialogDismiss(true)
}
}
/**
* Property delegate mirroring the state of a boolean value in shared preferences.
*
* [init] **must** be called before accessing the delegated property.
*/
class LiveBooleanPreference(
private val key: String,
private val defaultValue: Boolean = false,
private val onChange: ((value: Boolean) -> Unit)? = null
) {
private lateinit var sharedPreferences: SharedPreferences
private var value = defaultValue
private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == this.key) {
reload()
onChange?.invoke(value)
}
}
fun init(context: Context) = init(PreferenceManager.getDefaultSharedPreferences(context))
fun init(sharedPreferences: SharedPreferences) {
this.sharedPreferences = sharedPreferences
reload()
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
}
private fun reload() {
value = sharedPreferences.getBoolean(key, defaultValue)
}
operator fun getValue(thisRef: Any, property: KProperty<*>) = value
companion object {
fun init(context: Context, vararg liveBooleanPreferences: LiveBooleanPreference) {
init(PreferenceManager.getDefaultSharedPreferences(context), *liveBooleanPreferences)
}
fun init(sharedPreferences: SharedPreferences, vararg liveBooleanPreferences: LiveBooleanPreference) {
for (i in liveBooleanPreferences) {
i.init(sharedPreferences)
}
}
}
}
}

View File

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

View File

@ -1,21 +0,0 @@
package sushi.hardcore.droidfs.util
import android.content.Intent
import android.os.Build
import android.os.Parcelable
object IntentUtils {
inline fun <reified T: Parcelable> getParcelableExtra(intent: Intent, name: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(name, T::class.java)
} else {
@Suppress("Deprecation")
intent.getParcelableExtra(name)
}
}
fun forwardIntent(sourceIntent: Intent, targetIntent: Intent) {
targetIntent.action = sourceIntent.action
sourceIntent.extras?.let { targetIntent.putExtras(it) }
}
}

View File

@ -1,3 +0,0 @@
package sushi.hardcore.droidfs.util
class ObjRef<T>(var value: T)

View File

@ -1,21 +0,0 @@
package sushi.hardcore.droidfs.util
import android.app.Activity
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
abstract class Observable<T> {
protected val observers = mutableListOf<T>()
fun observe(observer: T) {
observers.add(observer)
}
}
fun Activity.finishOnClose(encryptedVolume: EncryptedVolume) {
encryptedVolume.observe(object : EncryptedVolume.Observer {
override fun onClose() {
finish()
// no need to remove observer as the EncryptedVolume will be destroyed
}
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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