Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
82dda95211 | |||
40bed2db21 | |||
f901495e41 | |||
07f5f8b5d9 | |||
2d5f5a82c9 | |||
b477272d65 | |||
88bd746359 | |||
9872cab7c2 | |||
4aa211bca4 | |||
0a1406769b | |||
a62f32e364 | |||
f865c864a2 | |||
e804059b23 | |||
|
bb821d5f41 | ||
6c0e20c68e | |||
e9b67bd9c4 | |||
c0dcaed8d2 | |||
85e24921fa | |||
15f288be11 | |||
bb49501403 | |||
33d565bf22 | |||
52a29b034c |
3
.gitmodules
vendored
@ -7,3 +7,6 @@
|
||||
[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
|
||||
|
38
BUILD.md
@ -38,31 +38,17 @@ __Don't continue if the verification fails!__
|
||||
|
||||
Initialize submodules:
|
||||
```
|
||||
$ git submodule update --depth=1 --init
|
||||
$ git submodule update --init
|
||||
```
|
||||
[FFmpeg](https://ffmpeg.org) is needed to record encrypted video:
|
||||
If you want Gocryptfs support, initliaze libgocryptfs submodules:
|
||||
```
|
||||
$ cd app/ffmpeg
|
||||
$ git clone --depth=1 https://git.ffmpeg.org/ffmpeg.git
|
||||
$ cd app/libgocryptfs
|
||||
$ git submodule update --init
|
||||
```
|
||||
If you want Gocryptfs support, you need to download OpenSSL:
|
||||
```
|
||||
$ cd ../libgocryptfs
|
||||
$ wget https://openssl.org/source/openssl-3.3.1.tar.gz
|
||||
```
|
||||
Verify OpenSSL signature:
|
||||
```
|
||||
$ https://openssl.org/source/openssl-3.3.1.tar.gz.asc
|
||||
$ gpg --verify openssl-3.3.1.tar.gz.asc openssl-3.3.1.tar.gz
|
||||
```
|
||||
Continue **ONLY** if the signature is **VALID**.
|
||||
```
|
||||
$ tar -xzf openssl-3.3.1.tar.gz
|
||||
```
|
||||
If you want CryFS support, initialize libcryfs:
|
||||
If you want CryFS support, initialize libcryfs submodules:
|
||||
```
|
||||
$ cd app/libcryfs
|
||||
$ git submodule update --depth=1 --init
|
||||
$ git submodule update --init
|
||||
```
|
||||
|
||||
# Build
|
||||
@ -70,31 +56,33 @@ 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 ffmpeg
|
||||
$ ./build.sh [<ABI>]
|
||||
```
|
||||
## libgocryptfs
|
||||
This step is only required if you want Gocryptfs support.
|
||||
```
|
||||
$ cd app/libgocryptfs
|
||||
$ ANDROID_NDK_ROOT="$ANDROID_NDK_HOME" OPENSSL_PATH="./openssl-3.3.1" ./build.sh
|
||||
$ ./build.sh [<ABI>]
|
||||
```
|
||||
## Compile APKs
|
||||
Gradle build libgocryptfs and libcryfs by default.
|
||||
|
||||
To build DroidFS without Gocryptfs support, run:
|
||||
```
|
||||
$ ./gradlew assembleRelease -PdisableGocryptfs=true
|
||||
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableGocryptfs=true
|
||||
```
|
||||
To build DroidFS without CryFS support, run:
|
||||
```
|
||||
$ ./gradlew assembleRelease -PdisableCryFS=true
|
||||
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableCryFS=true
|
||||
```
|
||||
If you want to build DroidFS with support for both Gocryptfs and CryFS, just run:
|
||||
```
|
||||
$ ./gradlew assembleRelease
|
||||
$ ./gradlew assembleRelease [-Pabi=<ABI>]
|
||||
```
|
||||
|
||||
# Sign APKs
|
||||
|
77
README.md
@ -28,41 +28,47 @@ Do not use this app with volumes containing sensitive data unless you know exact
|
||||
- Unlocking volumes using fingerprint authentication
|
||||
- Volume auto-locking when the app goes in background
|
||||
|
||||
_For upcoming features, see [TODO.md](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/TODO.md)._
|
||||
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.
|
||||
|
||||
<ul>
|
||||
<li><h4>Allow screenshots:</h4>
|
||||
<li><b>Allow screenshots:</b>
|
||||
|
||||
Disable the secure flag of DroidFS activities. This will allow you to take screenshots from the app, but will also allow other apps to record the screen while using DroidFS.
|
||||
|
||||
Note: apps with root access don't care about this flag: they can take screenshots or record the screen of any app without any permissions.
|
||||
</li>
|
||||
<li><h4>Allow 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>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>
|
||||
<li><h4>Keep volume open when the app goes in background:</h4>
|
||||
Don't close the volume when you leave the app but keep running it in the background. Anyone going back to the activity could have access to the volume.
|
||||
</li>
|
||||
<li><h4>Allow opening files with other applications*:</h4>
|
||||
Decrypt and open file using external apps. These apps could save and send the files thus opened.
|
||||
</li>
|
||||
<li><h4>Expose open volumes*:</h4>
|
||||
Allow open volumes to be browsed in the system file explorer (<a href="https://developer.android.com/guide/topics/providers/document-provider">DocumentProvider</a> API). Encrypted files can then be selected from other applications, potentially with permanent access. This feature requires <i>"Keep volume open when the app goes in background"</i> to be enabled.
|
||||
</li>
|
||||
<li><h4>Grant write access:</h4>
|
||||
Files opened with another applications can be modified by them. This applies to both previous unsafe features.
|
||||
</li>
|
||||
Note: apps with root access don't care about this flag: they can take screenshots or record the screen of any app without any permissions.</li>
|
||||
<li><b>Allow exporting files:</b>
|
||||
|
||||
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.</li>
|
||||
<li><b>Allow sharing files via the android share menu⁽¹⁾:</b>
|
||||
|
||||
Decrypt and share file with other apps. These apps could save and send the files thus shared.</li>
|
||||
<li><b>Allow saving password hash using fingerprint:</b>
|
||||
|
||||
Generate an AES-256 GCM key in the Android Keystore (protected by fingerprint authentication), then use it to encrypt the volume password hash and store it to the DroidFS internal storage. This require Android v6.0+. If your device is not encrypted, extracting the encryption key with physical access may be possible.</li>
|
||||
<li><b>Disable volume auto-locking:</b> (previously called <i>"Keep volumes open when the app goes in background"</i>)
|
||||
|
||||
Don't close open volumes when you leave the app. Anyone going back to the application could have access to open volumes. Cryptographic secrets are kept in memory for an undefined amount of time.</li>
|
||||
<li><b>Keep volumes open:</b>
|
||||
(Different from the old <i>"Keep volumes open when the app goes in background"</i>. Yes it's confusing, sorry)
|
||||
|
||||
Keep the app running as a [foreground service](https://developer.android.com/develop/background-work/services/foreground-services) to maintain volumes open, even when the app is removed from recent tasks.
|
||||
|
||||
This avoid the app from being killed by the system during file operations or while accessing exposed volumes, but this mean cryptographic secrets stay in memory for an undefined amount of time.</li>
|
||||
<li><b>Allow opening files with other applications⁽¹⁾:</b>
|
||||
|
||||
Decrypt and open file using external apps. These apps could save and send the files thus opened.</li>
|
||||
<li><b>Expose open volumes⁽¹⁾:</b>
|
||||
|
||||
Allow open volumes to be browsed in the system file explorer (<a href="https://developer.android.com/guide/topics/providers/document-provider">DocumentProvider</a> API). Encrypted files can then be selected from other applications, potentially with permanent access. This feature requires <i>"Disable volume auto-locking"</i>, and works more reliably when <i>"Keep volumes open"</i> is also enabled.</li>
|
||||
<li><b>Grant write access:</b>
|
||||
|
||||
Files opened with another applications can be modified by them. This applies to both previous unsafe features.</li>
|
||||
</ul>
|
||||
|
||||
\* 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.
|
||||
⁽¹⁾: These features can work in two ways: temporarily writing the plain file to disk (DroidFS internal storage) or sharing it via memory. By default, DroidFS will choose to keep the file only in memory as it's more secure, but will fallback to disk export if the file is too large to be held in memory. This behavior can be changed with the *"Export method"* parameter in the settings. Please note that some applications require the file to be stored on disk, and therefore do not work with memory-exported files.
|
||||
|
||||
# Download
|
||||
<a href="https://f-droid.org/packages/sushi.hardcore.droidfs">
|
||||
@ -90,20 +96,11 @@ F-Droid APKs should be signed with the F-Droid key. More details [here](https://
|
||||
# Permissions
|
||||
DroidFS needs some permissions for certain features. However, you are free to deny them if you do not wish to use these features.
|
||||
|
||||
<ul>
|
||||
<li><h4>Read & write access to shared storage:</h4>
|
||||
Required to access volumes located on shared storage.
|
||||
</li>
|
||||
<li><h4>Biometric/Fingerprint hardware:</h4>
|
||||
Required to encrypt/decrypt password hashes using a fingerprint protected key.
|
||||
</li>
|
||||
<li><h4>Camera:</h4>
|
||||
Required to take encrypted photos or videos directly from the app.
|
||||
</li>
|
||||
<li><h4>Record audio:</h4>
|
||||
Required if you want sound on video recorded with DroidFS.
|
||||
</li>
|
||||
</ul>
|
||||
- **Read & write access to shared storage**: Required to access volumes located on shared storage.
|
||||
- **Biometric/Fingerprint hardware**: Required to encrypt/decrypt password hashes using a fingerprint protected key.
|
||||
- **Camera**: Required to take encrypted photos or videos directly from the app.
|
||||
- **Record audio**: Required if you want sound on video recorded with DroidFS.
|
||||
- **Notifications**: Used to report file operations progress and notify about volumes kept open.
|
||||
|
||||
# Limitations
|
||||
DroidFS 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.
|
||||
|
7
TODO.md
@ -8,8 +8,9 @@ Here's a list of features that it would be nice to have in DroidFS. As this is a
|
||||
|
||||
## UX
|
||||
- File associations editor
|
||||
- Optional discovery before file operations
|
||||
- Modifiable CryFS scrypt parameters
|
||||
- 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
|
||||
|
||||
@ -18,8 +19,6 @@ Here's a list of features that it would be nice to have in DroidFS. As this is a
|
||||
- [fscrypt](https://www.kernel.org/doc/html/latest/filesystems/fscrypt.html): filesystem encryption at the kernel level
|
||||
|
||||
## Health
|
||||
- F-Droid ABI split
|
||||
- OpenSSL & FFmpeg as git submodules (useful for F-Droid)
|
||||
- Remove all android:configChanges from AndroidManifest.xml
|
||||
- More efficient thumbnails cache
|
||||
- Guide for translators
|
||||
|
@ -13,12 +13,6 @@ if (hasProperty("disableGocryptfs")) {
|
||||
ext.disableGocryptfs = false
|
||||
}
|
||||
|
||||
if (hasProperty("nosplits")) {
|
||||
ext.splits = false
|
||||
} else {
|
||||
ext.splits = true
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 34
|
||||
ndkVersion '25.2.9519653'
|
||||
@ -33,15 +27,26 @@ android {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
def abiCodes = [ "x86": 1, "x86_64": 2, "armeabi-v7a": 3, "arm64-v8a": 4]
|
||||
|
||||
defaultConfig {
|
||||
applicationId "sushi.hardcore.droidfs"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 32
|
||||
versionCode 36
|
||||
versionName "2.1.3"
|
||||
targetSdkVersion 34
|
||||
versionCode 37
|
||||
versionName "2.2.0"
|
||||
|
||||
ndk {
|
||||
abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild.cmake {
|
||||
@ -54,20 +59,20 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
if (project.ext.splits) {
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset() // fix unknown bug (https://ru.stackoverflow.com/questions/1557805/abis-armeabi-mips-mips64-riscv64-are-not-supported-for-platform)
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.configureEach { variant ->
|
||||
variant.resValue "string", "versionName", variant.versionName
|
||||
buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}"
|
||||
buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}"
|
||||
variant.outputs.each { output ->
|
||||
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
if (abi == null) { // universal
|
||||
output.versionCodeOverride = variant.versionCode*10
|
||||
output.outputFileName = "DroidFS-v${variant.versionName}-${variant.name}-universal.apk"
|
||||
} else {
|
||||
output.versionCodeOverride = variant.versionCode*10 + abiCodes[abi]
|
||||
output.outputFileName = "DroidFS-v${variant.versionName}-${variant.name}-${abi}.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@ -107,11 +112,10 @@ dependencies {
|
||||
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.1"
|
||||
def lifecycle_version = "2.8.3"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
|
||||
|
||||
implementation "androidx.sqlite:sqlite-ktx:2.4.0"
|
||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
@ -123,14 +127,14 @@ dependencies {
|
||||
implementation "androidx.media3:media3-ui:$media3_version"
|
||||
implementation "androidx.media3:media3-datasource:$media3_version"
|
||||
|
||||
implementation "androidx.concurrent:concurrent-futures:1.1.0"
|
||||
|
||||
def camerax_version = "1.3.3"
|
||||
def camerax_version = "1.3.4"
|
||||
implementation "androidx.camera:camera-camera2:$camerax_version"
|
||||
implementation "androidx.camera:camera-lifecycle:$camerax_version"
|
||||
implementation "androidx.camera:camera-view:$camerax_version"
|
||||
implementation "androidx.camera:camera-extensions:$camerax_version"
|
||||
|
||||
// dependencies needed by CameraX patch
|
||||
implementation "androidx.concurrent:concurrent-futures:1.2.0"
|
||||
def autoValueVersion = '1.10.4'
|
||||
implementation "com.google.auto.value:auto-value-annotations:$autoValueVersion"
|
||||
annotationProcessor "com.google.auto.value:auto-value:$autoValueVersion"
|
||||
|
1
app/ffmpeg/.gitignore
vendored
@ -1,2 +1 @@
|
||||
ffmpeg
|
||||
build
|
||||
|
@ -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
|
||||
FFMPEG_DIR=$1
|
||||
cd "$(dirname "$0")"
|
||||
FFMPEG_DIR="ffmpeg"
|
||||
compile_for_arch() {
|
||||
echo "Compiling for $1..."
|
||||
case $1 in
|
||||
@ -29,7 +29,8 @@ else
|
||||
ARCH="arm"
|
||||
;;
|
||||
esac
|
||||
(cd $FFMPEG_DIR && make clean;
|
||||
(cd $FFMPEG_DIR
|
||||
make clean || true
|
||||
./configure \
|
||||
--cc="$CFN" \
|
||||
--cxx="$CFN++" \
|
||||
@ -73,22 +74,19 @@ 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 ||
|
||||
exit 1
|
||||
--disable-debug
|
||||
make -j "$(nproc --all)" >/dev/null)
|
||||
mkdir -p "build/$1/libavformat" "build/$1/libavcodec" "build/$1/libavutil"
|
||||
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so "build/$1/libavformat"
|
||||
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so "build/$1/libavcodec"
|
||||
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so "build/$1/libavutil"
|
||||
}
|
||||
|
||||
export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
|
||||
if [ $# -eq 2 ]; then
|
||||
compile_for_arch $2
|
||||
if [ $# -eq 1 ]; then
|
||||
compile_for_arch "$1"
|
||||
else
|
||||
declare -a ABIs=("x86_64" "x86" "arm64-v8a" "armeabi-v7a")
|
||||
for abi in ${ABIs[@]}; do
|
||||
for abi in "x86_64" "x86" "arm64-v8a" "armeabi-v7a"; do
|
||||
compile_for_arch $abi
|
||||
done
|
||||
fi
|
||||
|
1
app/ffmpeg/ffmpeg
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit af25a4bfd2503caf3ee485b27b99b620302f5718
|
@ -1 +1 @@
|
||||
Subproject commit 0398d48b0963c01092976c5c7012b02327e564f0
|
||||
Subproject commit cd0af7088066f870f12eceed9836bde897f1d164
|
@ -1 +1 @@
|
||||
Subproject commit 4f32853ae5ac70811b451cac60ed36fd5b93cbc8
|
||||
Subproject commit b221d4cf3c70edc711169a769a476802d2577b2b
|
6
app/proguard-rules.pro
vendored
@ -16,9 +16,3 @@
|
||||
-keep class sushi.hardcore.droidfs.VolumeData$* {
|
||||
static public android.os.Parcelable$Creator CREATOR;
|
||||
}
|
||||
-keep class sushi.hardcore.droidfs.filesystems.EncryptedVolume {
|
||||
public int describeContents();
|
||||
}
|
||||
-keep class sushi.hardcore.droidfs.filesystems.EncryptedVolume$* {
|
||||
static public android.os.Parcelable$Creator CREATOR;
|
||||
}
|
@ -4,6 +4,9 @@
|
||||
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" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
@ -55,10 +58,11 @@
|
||||
<activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
|
||||
<activity android:name=".LogcatActivity"/>
|
||||
|
||||
<service android:name=".WiperService" android:exported="false" android:stopWithTask="false"/>
|
||||
<service android:name=".file_operations.FileOperationService" android:exported="false"/>
|
||||
<service android:name=".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"/>
|
||||
|
||||
<receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false">
|
||||
<receiver android:name=".NotificationBroadcastReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="file_operation_cancel"/>
|
||||
</intent-filter>
|
||||
|
@ -4,6 +4,7 @@ import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.preference.PreferenceManager
|
||||
|
||||
open class BaseActivity: AppCompatActivity() {
|
||||
protected lateinit var sharedPrefs: SharedPreferences
|
||||
@ -12,7 +13,7 @@ open class BaseActivity: AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedPrefs = (application as VolumeManagerApp).sharedPreferences
|
||||
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
theme = Theme.fromSharedPrefs(sharedPrefs)
|
||||
if (applyCustomTheme) {
|
||||
setTheme(theme.toResourceId())
|
||||
|
@ -7,7 +7,10 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.util.Size
|
||||
import android.view.*
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.Surface
|
||||
import android.view.View
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.view.animation.RotateAnimation
|
||||
@ -42,8 +45,8 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.databinding.ActivityCameraBinding
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.IntentUtils
|
||||
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
|
||||
@ -52,7 +55,9 @@ import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.Random
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
@ -113,7 +118,10 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
binding = ActivityCameraBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.hide()
|
||||
encryptedVolume = IntentUtils.getParcelableExtra(intent, "volume")!!
|
||||
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
|
||||
intent.getIntExtra("volumeId", -1)
|
||||
)!!
|
||||
finishOnClose(encryptedVolume)
|
||||
outputDirectory = intent.getStringExtra("path")!!
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@ -577,12 +585,8 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (encryptedVolume.isClosed()) {
|
||||
finish()
|
||||
} else {
|
||||
sensorOrientationListener.addListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOrientationChange(newOrientation: Int) {
|
||||
val realOrientation = when (newOrientation) {
|
||||
|
20
app/src/main/java/sushi/hardcore/droidfs/ClosingService.kt
Normal file
@ -0,0 +1,20 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Dummy background service listening for application task removal in order to
|
||||
* close all volumes still open on quit.
|
||||
*
|
||||
* Should only be running when usfBackground is enabled AND usfKeepOpen is disabled.
|
||||
*/
|
||||
class ClosingService : Service() {
|
||||
override fun onBind(intent: Intent) = null
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
(application as VolumeManagerApp).volumeManager.closeAll()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
@ -9,6 +9,8 @@ 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
|
||||
@ -86,9 +88,11 @@ class EncryptedFileProvider(context: Context) {
|
||||
}
|
||||
|
||||
override fun free() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Wiper.wipe(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExportedMemFile private constructor(path: String, private val file: MemFile) :
|
||||
ExportedFile(path) {
|
||||
@ -160,8 +164,10 @@ class EncryptedFileProvider(context: Context) {
|
||||
exportedFile: ExportedFile,
|
||||
encryptedVolume: EncryptedVolume,
|
||||
): Boolean {
|
||||
val fd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false).fileDescriptor
|
||||
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(fd))
|
||||
val pfd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false)
|
||||
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(pfd.fileDescriptor)).also {
|
||||
pfd.close()
|
||||
}
|
||||
}
|
||||
|
||||
enum class Error {
|
||||
|
@ -4,8 +4,8 @@ import java.io.File
|
||||
|
||||
object FileTypes {
|
||||
private val FILE_EXTENSIONS = mapOf(
|
||||
Pair("image", listOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "heic")),
|
||||
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
|
||||
Pair("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(
|
||||
|
120
app/src/main/java/sushi/hardcore/droidfs/KeepAliveService.kt
Normal file
@ -0,0 +1,120 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
|
||||
class KeepAliveService: Service() {
|
||||
internal class NotificationDetails(
|
||||
val channel: String,
|
||||
val title: String,
|
||||
val text: String,
|
||||
val action: NotificationAction,
|
||||
) : Parcelable {
|
||||
internal class NotificationAction(
|
||||
val icon: Int,
|
||||
val title: String,
|
||||
val action: String,
|
||||
)
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
NotificationAction(
|
||||
parcel.readInt(),
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
)
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
with (parcel) {
|
||||
writeString(channel)
|
||||
writeString(title)
|
||||
writeString(text)
|
||||
writeInt(action.icon)
|
||||
writeString(action.title)
|
||||
writeString(action.action)
|
||||
}
|
||||
}
|
||||
|
||||
override fun describeContents() = 0
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<NotificationDetails> {
|
||||
override fun createFromParcel(parcel: Parcel) = NotificationDetails(parcel)
|
||||
override fun newArray(size: Int) = arrayOfNulls<NotificationDetails>(size)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "start"
|
||||
|
||||
/**
|
||||
* If [startForeground] is called before notification permission is granted,
|
||||
* the notification won't appear.
|
||||
*
|
||||
* This action can be used once the permission is granted, to make the service
|
||||
* call [startForeground] again in order to properly show the notification.
|
||||
*/
|
||||
const val ACTION_FOREGROUND = "foreground"
|
||||
const val NOTIFICATION_CHANNEL_ID = "KeepAlive"
|
||||
}
|
||||
|
||||
private val notificationManager by lazy {
|
||||
NotificationManagerCompat.from(this)
|
||||
}
|
||||
private var notification: Notification? = null
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
if (intent.action == ACTION_START) {
|
||||
val notificationDetails = IntentCompat.getParcelableExtra(intent, "notification", NotificationDetails::class.java)!!
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
notificationDetails.channel,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
)
|
||||
}
|
||||
notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(notificationDetails.title)
|
||||
.setContentText(notificationDetails.text)
|
||||
.addAction(NotificationCompat.Action(
|
||||
notificationDetails.action.icon,
|
||||
notificationDetails.action.title,
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||
action = notificationDetails.action.action
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
))
|
||||
.build()
|
||||
}
|
||||
ServiceCompat.startForeground(this, startId, notification!!, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC // is there a better use case flag?
|
||||
} else {
|
||||
0
|
||||
})
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
@ -1,12 +1,8 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
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
|
||||
@ -131,14 +127,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
}
|
||||
}
|
||||
}
|
||||
startService(Intent(this, WiperService::class.java))
|
||||
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)
|
||||
FileOperationService.bind(this) {
|
||||
fileOperationService = it
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,9 +179,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
}
|
||||
|
||||
private fun unselect(position: Int) {
|
||||
volumeAdapter.selectedItems.remove(position)
|
||||
volumeAdapter.onVolumeChanged(position)
|
||||
onSelectionChanged(0) // unselect() is always called when only one element is selected
|
||||
volumeAdapter.unselect(position)
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
@ -290,7 +278,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
R.id.delete_password_hash -> {
|
||||
for (i in volumeAdapter.selectedItems) {
|
||||
if (volumeDatabase.removeHash(volumeAdapter.volumes[i]))
|
||||
volumeAdapter.onVolumeChanged(i)
|
||||
volumeAdapter.onVolumeDataChanged(i)
|
||||
}
|
||||
unselectAll(false)
|
||||
true
|
||||
@ -311,6 +299,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
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))
|
||||
@ -434,9 +423,6 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
lifecycleScope.launch {
|
||||
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
|
||||
when (result.taskResult.state) {
|
||||
TaskResult.State.CANCELLED -> {
|
||||
result.dstRootDirectory?.delete()
|
||||
}
|
||||
TaskResult.State.SUCCESS -> {
|
||||
result.dstRootDirectory?.let {
|
||||
getResultVolume(it)?.let { volume ->
|
||||
@ -458,6 +444,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
.show()
|
||||
}
|
||||
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
|
||||
TaskResult.State.CANCELLED -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -482,6 +469,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
if (success) {
|
||||
volumeDatabase.renameVolume(volume, newDBName)
|
||||
VolumeProvider.notifyRootsChanged(this)
|
||||
volumeAdapter.onVolumeDataChanged(position)
|
||||
unselect(position)
|
||||
if (volume.name == volumeOpener.defaultVolumeName) {
|
||||
with (sharedPrefs.edit()) {
|
||||
|
@ -0,0 +1,23 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import sushi.hardcore.droidfs.file_operations.FileOperationService
|
||||
|
||||
class NotificationBroadcastReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
FileOperationService.ACTION_CANCEL -> {
|
||||
intent.getBundleExtra("bundle")?.let { bundle ->
|
||||
// TODO: use peekService instead?
|
||||
val binder = (bundle.getBinder("binder") as FileOperationService.LocalBinder?)
|
||||
binder?.getService()?.cancelOperation(bundle.getInt("taskId"))
|
||||
}
|
||||
}
|
||||
VolumeManagerApp.ACTION_CLOSE_ALL_VOLUMES -> {
|
||||
(context.applicationContext as VolumeManagerApp).volumeManager.closeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -8,21 +8,23 @@ 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.TemporaryFileProvider
|
||||
import sushi.hardcore.droidfs.content_providers.VolumeProvider
|
||||
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)
|
||||
@ -169,38 +171,56 @@ 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 updateView(usfOpen: Boolean? = null, usfKeepOpen: Boolean? = null, usfExpose: Boolean? = null) {
|
||||
val usfKeepOpen = usfKeepOpen ?: switchKeepOpen.isChecked
|
||||
switchExpose.isEnabled = usfKeepOpen
|
||||
switchSafWrite.isEnabled = usfOpen ?: switchExternalOpen.isChecked || (usfKeepOpen && usfExpose ?: switchExpose.isChecked)
|
||||
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)
|
||||
|
||||
updateView()
|
||||
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
|
||||
updateView(usfKeepOpen = checked as Boolean)
|
||||
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 ->
|
||||
updateView(usfOpen = checked as Boolean)
|
||||
updateSafWrite(usfOpen = checked as Boolean)
|
||||
true
|
||||
}
|
||||
switchExpose.setOnPreferenceChangeListener { _, checked ->
|
||||
VolumeProvider.usfExpose = checked as Boolean
|
||||
updateView(usfExpose = checked)
|
||||
updateSafWrite(usfExpose = checked as Boolean)
|
||||
VolumeProvider.notifyRootsChanged(requireContext())
|
||||
true
|
||||
}
|
||||
switchSafWrite.setOnPreferenceChangeListener { _, checked ->
|
||||
VolumeProvider.usfSafWrite = checked as Boolean
|
||||
TemporaryFileProvider.usfSafWrite = checked
|
||||
|
||||
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)
|
||||
|
@ -79,10 +79,12 @@ class VolumeData(
|
||||
if (other !is VolumeData) {
|
||||
return false
|
||||
}
|
||||
return other.uuid == uuid
|
||||
return other.uuid == uuid || (other.name == name && other.isHidden == isHidden)
|
||||
}
|
||||
|
||||
override fun hashCode() = uuid.hashCode()
|
||||
override fun hashCode(): Int {
|
||||
return name.hashCode()+isHidden.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val VOLUMES_DIRECTORY = "volumes"
|
||||
|
@ -7,8 +7,14 @@ 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() {}
|
||||
}
|
||||
|
||||
class VolumeManager(private val context: Context) {
|
||||
private var id = 0
|
||||
private val volumes = HashMap<Int, EncryptedVolume>()
|
||||
private val volumesData = HashMap<VolumeData, Int>()
|
||||
@ -17,6 +23,7 @@ class VolumeManager(private val context: Context) {
|
||||
fun insert(volume: EncryptedVolume, data: VolumeData): Int {
|
||||
volumes[id] = volume
|
||||
volumesData[data] = id
|
||||
observers.forEach { it.onVolumeStateChanged(data) }
|
||||
VolumeProvider.notifyRootsChanged(context)
|
||||
return id++
|
||||
}
|
||||
@ -37,6 +44,8 @@ class VolumeManager(private val context: Context) {
|
||||
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 }
|
||||
}
|
||||
@ -44,9 +53,10 @@ class VolumeManager(private val context: Context) {
|
||||
fun closeVolume(id: Int) {
|
||||
volumes.remove(id)?.let { volume ->
|
||||
scopes[id]?.cancel()
|
||||
volume.close()
|
||||
volumesData.filter { it.value == id }.forEach {
|
||||
volumesData.remove(it.key)
|
||||
volume.closeVolume()
|
||||
volumesData.filter { it.value == id }.forEach { entry ->
|
||||
volumesData.remove(entry.key)
|
||||
observers.forEach { it.onVolumeStateChanged(entry.key) }
|
||||
}
|
||||
VolumeProvider.notifyRootsChanged(context)
|
||||
}
|
||||
@ -55,10 +65,11 @@ class VolumeManager(private val context: Context) {
|
||||
fun closeAll() {
|
||||
volumes.forEach {
|
||||
scopes[it.key]?.cancel()
|
||||
it.value.close()
|
||||
it.value.closeVolume()
|
||||
}
|
||||
volumes.clear()
|
||||
volumesData.clear()
|
||||
observers.forEach { it.onAllVolumesClosed() }
|
||||
VolumeProvider.notifyRootsChanged(context)
|
||||
}
|
||||
}
|
@ -1,40 +1,88 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.preference.PreferenceManager
|
||||
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
|
||||
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||
|
||||
class VolumeManagerApp : Application(), DefaultLifecycleObserver {
|
||||
companion object {
|
||||
private const val USF_KEEP_OPEN_KEY = "usf_keep_open"
|
||||
const val ACTION_CLOSE_ALL_VOLUMES = "close_all"
|
||||
}
|
||||
|
||||
lateinit var sharedPreferences: SharedPreferences
|
||||
private val sharedPreferencesListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == USF_KEEP_OPEN_KEY) {
|
||||
reloadUsfKeepOpen()
|
||||
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 var usfKeepOpen = false
|
||||
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)
|
||||
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)
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this).apply {
|
||||
registerOnSharedPreferenceChangeListener(sharedPreferencesListener)
|
||||
}
|
||||
reloadUsfKeepOpen()
|
||||
AndroidUtils.LiveBooleanPreference.init(this, usfBackgroundDelegate, usfKeepOpenDelegate)
|
||||
}
|
||||
|
||||
private fun reloadUsfKeepOpen() {
|
||||
usfKeepOpen = sharedPreferences.getBoolean(USF_KEEP_OPEN_KEY, false)
|
||||
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) {
|
||||
@ -43,10 +91,10 @@ class VolumeManagerApp : Application(), DefaultLifecycleObserver {
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (!isStartingExternalApp) {
|
||||
if (!usfKeepOpen) {
|
||||
if (!usfBackground) {
|
||||
volumeManager.closeAll()
|
||||
}
|
||||
if (!usfKeepOpen || !isExporting) {
|
||||
if (!usfBackground || !isExporting) {
|
||||
TemporaryFileProvider.instance.wipe()
|
||||
}
|
||||
}
|
||||
|
@ -211,7 +211,7 @@ class VolumeOpener(
|
||||
private var isClosed = false
|
||||
override fun onFailed(pending: Boolean) {
|
||||
if (!isClosed) {
|
||||
encryptedVolume.close()
|
||||
encryptedVolume.closeVolume()
|
||||
isClosed = true
|
||||
}
|
||||
Arrays.fill(returnedHash.value!!, 0)
|
||||
|
@ -1,17 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
|
||||
class WiperService : Service() {
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
(application as VolumeManagerApp).volumeManager.closeAll()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
@ -40,6 +40,12 @@ 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)) {
|
||||
|
@ -29,6 +29,16 @@ class VolumeAdapter(
|
||||
|
||||
init {
|
||||
reloadVolumes()
|
||||
volumeManager.observe(object : VolumeManager.Observer {
|
||||
override fun onVolumeStateChanged(volume: VolumeData) {
|
||||
notifyItemChanged(volumes.indexOf(volume))
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onAllVolumesClosed() {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
@ -66,7 +76,7 @@ class VolumeAdapter(
|
||||
false
|
||||
}
|
||||
|
||||
fun onVolumeChanged(position: Int) {
|
||||
fun onVolumeDataChanged(position: Int) {
|
||||
reloadVolumes()
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
@ -1,6 +1,16 @@
|
||||
package sushi.hardcore.droidfs.add_volume
|
||||
|
||||
import sushi.hardcore.droidfs.R
|
||||
|
||||
enum class Action {
|
||||
OPEN,
|
||||
ADD,
|
||||
CREATE,
|
||||
;
|
||||
|
||||
fun getStringResId() = when (this) {
|
||||
OPEN -> R.string.open
|
||||
ADD -> R.string.add_volume
|
||||
CREATE -> R.string.create_volume
|
||||
}
|
||||
}
|
@ -67,18 +67,18 @@ class AddVolumeActivity: BaseActivity() {
|
||||
finish()
|
||||
}
|
||||
|
||||
fun onVolumeSelected(volume: VolumeData, rememberVolume: Boolean) {
|
||||
if (rememberVolume) {
|
||||
fun onVolumeAdded() {
|
||||
setResult(RESULT_USER_BACK)
|
||||
finish()
|
||||
} else {
|
||||
volumeOpener.openVolume(volume, false, object : VolumeOpener.VolumeOpenerCallbacks {
|
||||
}
|
||||
|
||||
fun openVolume(volume: VolumeData, isVolumeKnown: Boolean) {
|
||||
volumeOpener.openVolume(volume, isVolumeKnown, object : VolumeOpener.VolumeOpenerCallbacks {
|
||||
override fun onVolumeOpened(id: Int) {
|
||||
startExplorer(id, volume.shortName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun createVolume(volumePath: String, isHidden: Boolean, rememberVolume: Boolean) {
|
||||
supportFragmentManager
|
||||
|
@ -7,13 +7,23 @@ 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.*
|
||||
import sushi.hardcore.droidfs.BuildConfig
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.FingerprintProtector
|
||||
import sushi.hardcore.droidfs.LoadingTask
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.Theme
|
||||
import sushi.hardcore.droidfs.VolumeData
|
||||
import sushi.hardcore.droidfs.VolumeDatabase
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.databinding.FileSystemRadioBinding
|
||||
import sushi.hardcore.droidfs.databinding.FragmentCreateVolumeBinding
|
||||
import sushi.hardcore.droidfs.filesystems.CryfsVolume
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
@ -23,9 +33,11 @@ import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.UIUtils
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.Arrays
|
||||
|
||||
class CreateVolumeFragment: Fragment() {
|
||||
internal data class FileSystemInfo(val nameResource: Int, val detailsResource: Int, val ciphersResource: Int)
|
||||
|
||||
companion object {
|
||||
private const val KEY_THEME_VALUE = "theme"
|
||||
private const val KEY_VOLUME_PATH = "path"
|
||||
@ -34,6 +46,17 @@ class CreateVolumeFragment: Fragment() {
|
||||
private const val KEY_PIN_PASSWORDS = Constants.PIN_PASSWORDS_KEY
|
||||
private const val KEY_USF_FINGERPRINT = "fingerprint"
|
||||
|
||||
private val GOCRYPTFS_INFO = FileSystemInfo(
|
||||
R.string.gocryptfs,
|
||||
R.string.gocryptfs_details,
|
||||
R.array.gocryptfs_encryption_ciphers,
|
||||
)
|
||||
private val CRYFS_INFO = FileSystemInfo(
|
||||
R.string.cryfs,
|
||||
R.string.cryfs_details,
|
||||
R.array.cryfs_encryption_ciphers,
|
||||
)
|
||||
|
||||
fun newInstance(
|
||||
theme: Theme,
|
||||
volumePath: String,
|
||||
@ -57,7 +80,7 @@ class CreateVolumeFragment: Fragment() {
|
||||
|
||||
private lateinit var binding: FragmentCreateVolumeBinding
|
||||
private lateinit var theme: Theme
|
||||
private val volumeTypes = ArrayList<String>(2)
|
||||
private val fileSystemInfos = ArrayList<FileSystemInfo>(2)
|
||||
private lateinit var volumePath: String
|
||||
private var isHiddenVolume: Boolean = false
|
||||
private var rememberVolume: Boolean = false
|
||||
@ -92,17 +115,10 @@ class CreateVolumeFragment: Fragment() {
|
||||
binding.checkboxSavePassword.visibility = View.GONE
|
||||
}
|
||||
if (!BuildConfig.GOCRYPTFS_DISABLED) {
|
||||
volumeTypes.add(resources.getString(R.string.gocryptfs))
|
||||
fileSystemInfos.add(GOCRYPTFS_INFO)
|
||||
}
|
||||
if (!BuildConfig.CRYFS_DISABLED) {
|
||||
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)
|
||||
fileSystemInfos.add(CRYFS_INFO)
|
||||
}
|
||||
val encryptionCipherAdapter = ArrayAdapter(
|
||||
requireContext(),
|
||||
@ -111,19 +127,29 @@ class CreateVolumeFragment: Fragment() {
|
||||
).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
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)) {
|
||||
R.array.gocryptfs_encryption_ciphers
|
||||
} else {
|
||||
R.array.cryfs_encryption_ciphers
|
||||
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(ciphersArray).asList())
|
||||
addAll(resources.getStringArray(fs.ciphersResource).asList())
|
||||
}
|
||||
binding.radioGroupFilesystems.children.forEach {
|
||||
if (it != root) {
|
||||
it.findViewById<RadioButton>(R.id.radio).isChecked = false
|
||||
}
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
}
|
||||
binding.radioGroupFilesystems.addView(root)
|
||||
}
|
||||
}
|
||||
binding.spinnerCipher.adapter = encryptionCipherAdapter
|
||||
if (pinPasswords) {
|
||||
@ -145,6 +171,15 @@ 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
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun createVolume() {
|
||||
val password = UIUtils.encodeEditTextContent(binding.editPassword)
|
||||
val passwordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
|
||||
@ -173,11 +208,11 @@ class CreateVolumeFragment: Fragment() {
|
||||
val volumeFile = File(volumePath)
|
||||
if (!volumeFile.exists())
|
||||
volumeFile.mkdirs()
|
||||
val result = if (volumeTypes[binding.spinnerVolumeType.selectedItemPosition] == resources.getString(R.string.gocryptfs)) {
|
||||
val result = if (fileSystemInfos[getSelectedFileSystemIndex()] == GOCRYPTFS_INFO) {
|
||||
val xchacha = when (binding.spinnerCipher.selectedItemPosition) {
|
||||
0 -> 0
|
||||
1 -> 1
|
||||
else -> -1
|
||||
0 -> -1 // auto
|
||||
1 -> 0 // AES-GCM
|
||||
else -> 1 // XChaCha20-Poly1305
|
||||
}
|
||||
generateResult(GocryptfsVolume.createAndOpenVolume(
|
||||
volumePath,
|
||||
|
@ -2,6 +2,7 @@ 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
|
||||
@ -15,10 +16,14 @@ 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
|
||||
@ -35,6 +40,10 @@ 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"
|
||||
@ -74,6 +83,7 @@ class SelectPathFragment: Fragment() {
|
||||
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,
|
||||
@ -93,17 +103,13 @@ class SelectPathFragment: Fragment() {
|
||||
theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
|
||||
pickMode = arguments.getBoolean(KEY_PICK_MODE)
|
||||
}
|
||||
if (pickMode) {
|
||||
binding.buttonAction.text = getString(R.string.add_volume)
|
||||
}
|
||||
volumeDatabase = VolumeDatabase(requireContext())
|
||||
filesDir = requireContext().filesDir.path
|
||||
binding.containerHiddenVolume.setOnClickListener {
|
||||
binding.switchHiddenVolume.performClick()
|
||||
}
|
||||
binding.switchHiddenVolume.setOnClickListener {
|
||||
showRightSection()
|
||||
refreshStatus(binding.editVolumeName.text)
|
||||
updateUi()
|
||||
}
|
||||
binding.buttonPickDirectory.setOnClickListener {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@ -137,22 +143,41 @@ class SelectPathFragment: Fragment() {
|
||||
launchPickDirectory()
|
||||
}
|
||||
}
|
||||
binding.buttonEnterPath.setOnClickListener {
|
||||
inputViewModel.showEditText = true
|
||||
updateUi()
|
||||
binding.editVolumeName.requestFocus()
|
||||
(app.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(
|
||||
binding.editVolumeName,
|
||||
InputMethodManager.SHOW_IMPLICIT
|
||||
)
|
||||
}
|
||||
binding.editVolumeName.addTextChangedListener(object: TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
|
||||
refreshStatus(s)
|
||||
updateUi(s)
|
||||
}
|
||||
})
|
||||
binding.switchRemember.setOnCheckedChangeListener { _, _ -> refreshButtonText() }
|
||||
binding.editVolumeName.setOnEditorActionListener { _, _, _ -> onPathSelected(); true }
|
||||
binding.switchRemember.setOnCheckedChangeListener { _, _ -> updateUi() }
|
||||
binding.editVolumeName.setOnEditorActionListener { _, _, _ ->
|
||||
if (binding.editVolumeName.text.isEmpty()) {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
if (binding.switchHiddenVolume.isChecked) R.string.empty_volume_name else R.string.empty_volume_path,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
onPathSelected()
|
||||
}
|
||||
true
|
||||
}
|
||||
binding.buttonAction.setOnClickListener { onPathSelected() }
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
(activity as AddVolumeActivity).onFragmentLoaded(true)
|
||||
showRightSection()
|
||||
}
|
||||
|
||||
private fun launchPickDirectory() {
|
||||
@ -160,70 +185,82 @@ class SelectPathFragment: Fragment() {
|
||||
PathUtils.safePickDirectory(pickDirectory, requireContext(), theme)
|
||||
}
|
||||
|
||||
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
|
||||
private fun updateUi(volumeName: CharSequence = binding.editVolumeName.text) {
|
||||
var warning = -1
|
||||
fun updateWarning() {
|
||||
if (warning == -1) {
|
||||
binding.textWarning.isVisible = false
|
||||
} else {
|
||||
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
|
||||
binding.textWarning.isVisible = true
|
||||
binding.textWarning.text = getString(warning)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshButtonText() {
|
||||
binding.buttonAction.text = getString(
|
||||
if (pickMode || volumeAction == Action.ADD) {
|
||||
if (binding.switchRemember.isChecked || currentVolumeData != null) {
|
||||
R.string.add_volume
|
||||
val hidden = binding.switchHiddenVolume.isChecked
|
||||
binding.editVolumeName.isVisible = hidden || inputViewModel.showEditText
|
||||
binding.buttonPickDirectory.isVisible = !hidden
|
||||
binding.textOr.isVisible = !hidden && !inputViewModel.showEditText
|
||||
binding.buttonEnterPath.isVisible = !hidden && !inputViewModel.showEditText
|
||||
if (hidden) {
|
||||
binding.textLabel.text = getString(R.string.volume_name_label)
|
||||
binding.editVolumeName.hint = getString(R.string.volume_name_hint)
|
||||
} else {
|
||||
R.string.open_volume
|
||||
binding.textLabel.text = getString(R.string.volume_path_label)
|
||||
binding.editVolumeName.hint = getString(R.string.volume_path_hint)
|
||||
}
|
||||
} else {
|
||||
R.string.create_volume
|
||||
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
|
||||
}
|
||||
|
||||
private fun refreshStatus(content: CharSequence) {
|
||||
val path = File(getCurrentVolumePath())
|
||||
volumeAction = if (path.isDirectory) {
|
||||
if (path.list()?.isEmpty() == true || content.isEmpty()) Action.CREATE else Action.ADD
|
||||
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(content.toString(), binding.switchHiddenVolume.isChecked)
|
||||
volumeDatabase.getVolume(volumeName.toString(), hidden)
|
||||
}
|
||||
if (currentVolumeData != null) {
|
||||
warning = R.string.volume_alread_saved
|
||||
}
|
||||
binding.textWarning.visibility = if (volumeAction == Action.CREATE && pickMode) {
|
||||
binding.textWarning.text = getString(R.string.choose_existing_volume)
|
||||
binding.buttonAction.isEnabled = false
|
||||
View.VISIBLE
|
||||
} else {
|
||||
refreshButtonText()
|
||||
binding.buttonAction.isEnabled = true
|
||||
if (currentVolumeData == null) {
|
||||
View.GONE
|
||||
} else {
|
||||
binding.textWarning.text = getString(R.string.volume_alread_saved)
|
||||
View.VISIBLE
|
||||
}
|
||||
warning = R.string.choose_existing_volume
|
||||
}
|
||||
updateWarning()
|
||||
}
|
||||
|
||||
private fun onDirectoryPicked(uri: Uri) {
|
||||
val path = PathUtils.getFullPathFromTreeUri(uri, requireContext())
|
||||
if (path != null)
|
||||
binding.editVolumeName.setText(path)
|
||||
else
|
||||
if (path == null) {
|
||||
CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.path_error)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
inputViewModel.showEditText = true
|
||||
binding.editVolumeName.setText(path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentVolumePath(): String {
|
||||
@ -243,13 +280,7 @@ class SelectPathFragment: Fragment() {
|
||||
if (currentVolumeData == null) { // volume not known
|
||||
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)) {
|
||||
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)
|
||||
@ -263,14 +294,19 @@ class SelectPathFragment: Fragment() {
|
||||
onNewVolumeSelected(currentVolumeValue, isHidden)
|
||||
}
|
||||
} else {
|
||||
(activity as AddVolumeActivity).onVolumeSelected(currentVolumeData!!, true)
|
||||
with (activity as AddVolumeActivity) {
|
||||
if (volumeAction!! == Action.OPEN) {
|
||||
openVolume(currentVolumeData!!, true)
|
||||
} else {
|
||||
onVolumeAdded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNewVolumeSelected(currentVolumeValue: String, isHidden: Boolean) {
|
||||
val volumePath = getCurrentVolumePath()
|
||||
when (volumeAction!!) {
|
||||
Action.CREATE -> {
|
||||
if (volumeAction!! == Action.CREATE) {
|
||||
val volumeFile = File(volumePath)
|
||||
var goodDirectory = false
|
||||
if (volumeFile.isFile) {
|
||||
@ -285,10 +321,15 @@ class SelectPathFragment: Fragment() {
|
||||
errorDirectoryNotWritable(volumePath)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.dir_not_empty, Toast.LENGTH_SHORT).show()
|
||||
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()
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
R.string.listdir_null_error_msg,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
if (File(PathUtils.getParentPath(volumePath)).canWrite()) {
|
||||
@ -298,10 +339,13 @@ class SelectPathFragment: Fragment() {
|
||||
}
|
||||
}
|
||||
if (goodDirectory) {
|
||||
(activity as AddVolumeActivity).createVolume(volumePath, isHidden, binding.switchRemember.isChecked)
|
||||
(activity as AddVolumeActivity).createVolume(
|
||||
volumePath,
|
||||
isHidden,
|
||||
binding.switchRemember.isChecked
|
||||
)
|
||||
}
|
||||
}
|
||||
Action.ADD -> {
|
||||
} else {
|
||||
val volumeType = EncryptedVolume.getVolumeType(volumePath)
|
||||
if (volumeType < 0) {
|
||||
CustomAlertDialogBuilder(requireContext(), theme)
|
||||
@ -313,7 +357,7 @@ class SelectPathFragment: Fragment() {
|
||||
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
|
||||
.setPositiveButton(R.string.ok) { _, _ -> onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
|
||||
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext())) {
|
||||
dialog.setView(
|
||||
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
|
||||
@ -326,8 +370,7 @@ class SelectPathFragment: Fragment() {
|
||||
}
|
||||
dialog.show()
|
||||
} else {
|
||||
addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
|
||||
}
|
||||
onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -349,11 +392,17 @@ class SelectPathFragment: Fragment() {
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun addVolume(volumeName: String, isHidden: Boolean, volumeType: Byte) {
|
||||
private fun onExistingVolumeSelected(volumeName: String, isHidden: Boolean, volumeType: Byte) {
|
||||
val volumeData = VolumeData(VolumeData.newUuid(), volumeName, isHidden, volumeType)
|
||||
if (binding.switchRemember.isChecked) {
|
||||
volumeDatabase.saveVolume(volumeData)
|
||||
}
|
||||
(activity as AddVolumeActivity).onVolumeSelected(volumeData, binding.switchRemember.isChecked)
|
||||
with (activity as AddVolumeActivity) {
|
||||
if (volumeAction!! == Action.OPEN) {
|
||||
openVolume(volumeData, binding.switchRemember.isChecked)
|
||||
} else {
|
||||
onVolumeAdded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -10,7 +10,6 @@ import android.os.ParcelFileDescriptor
|
||||
import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
@ -18,6 +17,7 @@ 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
|
||||
@ -36,9 +36,10 @@ class TemporaryFileProvider : ContentProvider() {
|
||||
|
||||
lateinit var instance: TemporaryFileProvider
|
||||
private set
|
||||
var usfSafWrite = false
|
||||
}
|
||||
|
||||
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>()
|
||||
@ -46,8 +47,7 @@ class TemporaryFileProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean {
|
||||
return context?.let {
|
||||
volumeManager = (it.applicationContext as VolumeManagerApp).volumeManager
|
||||
usfSafWrite =
|
||||
PreferenceManager.getDefaultSharedPreferences(it).getBoolean("usf_saf_write", false)
|
||||
usfSafWriteDelegate.init(it)
|
||||
encryptedFileProvider = EncryptedFileProvider(it)
|
||||
instance = this
|
||||
val tmpFilesDir = EncryptedFileProvider.getTmpFilesDir(it)
|
||||
|
@ -18,6 +18,7 @@ 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
|
||||
|
||||
@ -40,23 +41,23 @@ class VolumeProvider: DocumentsProvider() {
|
||||
DocumentsContract.Document.COLUMN_SIZE,
|
||||
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
|
||||
)
|
||||
var usfExpose = false
|
||||
var usfSafWrite = false
|
||||
|
||||
fun notifyRootsChanged(context: Context) {
|
||||
context.contentResolver.notifyChange(DocumentsContract.buildRootsUri(AUTHORITY), null)
|
||||
}
|
||||
}
|
||||
|
||||
private 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)
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
usfExpose = sharedPreferences.getBoolean("usf_expose", false)
|
||||
usfSafWrite = sharedPreferences.getBoolean("usf_saf_write", false)
|
||||
AndroidUtils.LiveBooleanPreference.init(context, usfExposeDelegate, usfSafWriteDelegate)
|
||||
volumeManager = (context.applicationContext as VolumeManagerApp).volumeManager
|
||||
encryptedFileProvider = EncryptedFileProvider(context)
|
||||
return true
|
||||
|
@ -1,12 +1,8 @@
|
||||
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
|
||||
@ -15,7 +11,6 @@ import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.addCallback
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@ -27,7 +22,6 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.launch
|
||||
@ -55,6 +49,7 @@ 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
|
||||
|
||||
@ -100,7 +95,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
usf_open = sharedPrefs.getBoolean("usf_open", false)
|
||||
volumeName = intent.getStringExtra("volumeName") ?: ""
|
||||
volumeId = intent.getIntExtra("volumeId", -1)
|
||||
encryptedVolume = app.volumeManager.getVolume(volumeId)!!
|
||||
encryptedVolume = app.volumeManager.getVolume(volumeId)!!.also { finishOnClose(it) }
|
||||
sortOrderEntries = resources.getStringArray(R.array.sort_orders_entries)
|
||||
sortOrderValues = resources.getStringArray(R.array.sort_orders_values)
|
||||
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||
@ -191,21 +186,15 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {}
|
||||
}, Context.BIND_AUTO_CREATE)
|
||||
FileOperationService.bind(this) {
|
||||
fileOperationService = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFileViewer(cls: Class<*>, filePath: String) {
|
||||
val intent = Intent(this, cls).apply {
|
||||
putExtra("path", filePath)
|
||||
putExtra("volume", encryptedVolume)
|
||||
putExtra("volumeId", volumeId)
|
||||
putExtra("sortOrder", sortOrderValues[currentSortOrderIndex])
|
||||
}
|
||||
startActivity(intent)
|
||||
@ -685,10 +674,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
if (app.isStartingExternalApp) {
|
||||
TemporaryFileProvider.instance.wipe()
|
||||
}
|
||||
if (encryptedVolume.isClosed()) {
|
||||
finish()
|
||||
} else {
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -181,7 +181,6 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
"importFromOtherVolumes" -> {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.action = "pick"
|
||||
intent.putExtra("volume", encryptedVolume)
|
||||
pickFromOtherVolumes.launch(intent)
|
||||
}
|
||||
"importFiles" -> {
|
||||
@ -205,7 +204,7 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
"camera" -> {
|
||||
val intent = Intent(this, CameraActivity::class.java)
|
||||
intent.putExtra("path", currentDirectoryPath)
|
||||
intent.putExtra("volume", encryptedVolume)
|
||||
intent.putExtra("volumeId", volumeId)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
@ -329,8 +328,8 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
activityScope.launch {
|
||||
onTaskResult(
|
||||
fileOperationService.moveElements(volumeId, toMove, toClean),
|
||||
R.string.move_success,
|
||||
R.string.move_failed,
|
||||
R.string.move_success,
|
||||
)
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
|
@ -1,5 +0,0 @@
|
||||
package sushi.hardcore.droidfs.file_operations
|
||||
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class FileOperationNotification(val notificationBuilder: NotificationCompat.Builder, val notificationId: Int)
|
@ -1,64 +1,183 @@
|
||||
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.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import sushi.hardcore.droidfs.BaseActivity
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.NotificationBroadcastReceiver
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.VolumeManager
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.filesystems.Stat
|
||||
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.util.Wiper
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Foreground service for file operations.
|
||||
*
|
||||
* Clients **must** bind to it using the [bind] method.
|
||||
*
|
||||
* This implementation is not thread-safe. It must only be called from the main UI thread.
|
||||
*/
|
||||
class FileOperationService : Service() {
|
||||
companion object {
|
||||
const val NOTIFICATION_CHANNEL_ID = "FileOperations"
|
||||
const val ACTION_CANCEL = "file_operation_cancel"
|
||||
}
|
||||
|
||||
private val binder = LocalBinder()
|
||||
private lateinit var volumeManger: VolumeManager
|
||||
private var serviceScope = MainScope()
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private val tasks = HashMap<Int, Job>()
|
||||
private var lastNotificationId = 0
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): FileOperationService = this@FileOperationService
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder {
|
||||
volumeManger = (application as VolumeManagerApp).volumeManager
|
||||
return binder
|
||||
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) }
|
||||
}
|
||||
|
||||
private fun showNotification(message: Int, total: Int?): FileOperationNotification {
|
||||
++lastNotificationId
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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) {
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
}
|
||||
@ -72,8 +191,7 @@ class FileOperationService : Service() {
|
||||
)
|
||||
}
|
||||
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
notificationBuilder
|
||||
.setContentTitle(getString(message))
|
||||
.setContentTitle(getString(task.title))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
.addAction(NotificationCompat.Action(
|
||||
@ -81,78 +199,179 @@ class FileOperationService : Service() {
|
||||
getString(R.string.cancel),
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
newTaskId,
|
||||
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||
val bundle = Bundle()
|
||||
bundle.putBinder("binder", LocalBinder())
|
||||
bundle.putInt("notificationId", lastNotificationId)
|
||||
putExtra("bundle", bundle)
|
||||
putExtra("bundle", Bundle().apply {
|
||||
putBinder("binder", LocalBinder())
|
||||
putInt("taskId", newTaskId)
|
||||
})
|
||||
action = ACTION_CANCEL
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
))
|
||||
if (total != null) {
|
||||
if (task.total != null) {
|
||||
notificationBuilder
|
||||
.setContentText("0/$total")
|
||||
.setProgress(total, 0, false)
|
||||
.setContentText("0/${task.total}")
|
||||
.setProgress(task.total, 0, false)
|
||||
} else {
|
||||
notificationBuilder
|
||||
.setContentText(getString(R.string.discovering_files))
|
||||
.setProgress(0, 0, true)
|
||||
}
|
||||
notificationManager.notify(lastNotificationId, notificationBuilder.build())
|
||||
return FileOperationNotification(notificationBuilder, lastNotificationId)
|
||||
showNotification(newTaskId, notificationBuilder.build())
|
||||
notifications[newTaskId] = notificationBuilder
|
||||
tasks[newTaskId] = task.start(newTaskId)
|
||||
newTaskId++
|
||||
}
|
||||
|
||||
private fun updateNotificationProgress(notification: FileOperationNotification, progress: Int, total: Int){
|
||||
notification.notificationBuilder
|
||||
private fun setForeground(id: Int, notification: Notification) {
|
||||
ServiceCompat.startForeground(this, id, notification,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
} else {
|
||||
0
|
||||
}
|
||||
)
|
||||
foregroundNotificationId = id
|
||||
}
|
||||
|
||||
private fun updateNotificationProgress(taskId: Int, progress: Int, total: Int) {
|
||||
val notificationBuilder = notifications[taskId] ?: return
|
||||
notificationBuilder
|
||||
.setProgress(total, progress, false)
|
||||
.setContentText("$progress/$total")
|
||||
notificationManager.notify(notification.notificationId, notification.notificationBuilder.build())
|
||||
notificationManager.notify(taskId, notificationBuilder.build())
|
||||
}
|
||||
|
||||
private fun cancelNotification(notification: FileOperationNotification){
|
||||
notificationManager.cancel(notification.notificationId)
|
||||
}
|
||||
|
||||
fun cancelOperation(notificationId: Int){
|
||||
tasks[notificationId]?.cancel()
|
||||
fun cancelOperation(taskId: Int) {
|
||||
tasks[taskId]?.cancel()
|
||||
}
|
||||
|
||||
private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
|
||||
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
|
||||
}
|
||||
|
||||
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<out T> {
|
||||
tasks[notification.notificationId] = task
|
||||
/**
|
||||
* 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 {
|
||||
cancelNotification(notification)
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> volumeTask(
|
||||
volumeId: Int,
|
||||
notification: FileOperationNotification,
|
||||
task: suspend (encryptedVolume: EncryptedVolume) -> T
|
||||
/**
|
||||
* 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> {
|
||||
return waitForTask(
|
||||
notification,
|
||||
volumeManger.getCoroutineScope(volumeId).async {
|
||||
task(getEncryptedVolume(volumeId))
|
||||
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(
|
||||
@ -196,9 +415,8 @@ class FileOperationService : Service() {
|
||||
items: List<OperationFile>,
|
||||
srcVolumeId: Int = volumeId,
|
||||
): TaskResult<out String?> {
|
||||
val notification = showNotification(R.string.file_op_copy_msg, items.size)
|
||||
val srcEncryptedVolume = getEncryptedVolume(srcVolumeId)
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
return volumeTask(R.string.file_op_copy_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||
var failedItem: String? = null
|
||||
for (i in items.indices) {
|
||||
yield()
|
||||
@ -212,7 +430,7 @@ class FileOperationService : Service() {
|
||||
failedItem = items[i].srcPath
|
||||
}
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(notification, i+1, items.size)
|
||||
updateNotificationProgress(taskId, i+1, items.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@ -222,8 +440,7 @@ class FileOperationService : Service() {
|
||||
}
|
||||
|
||||
suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
|
||||
val notification = showNotification(R.string.file_op_move_msg, toMove.size)
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
return volumeTask(R.string.file_op_move_msg, toMove.size, volumeId) { taskId, encryptedVolume ->
|
||||
val total = toMove.size+toClean.size
|
||||
var failedItem: String? = null
|
||||
for ((i, item) in toMove.withIndex()) {
|
||||
@ -231,7 +448,7 @@ class FileOperationService : Service() {
|
||||
failedItem = item.srcPath
|
||||
break
|
||||
} else {
|
||||
updateNotificationProgress(notification, i+1, total)
|
||||
updateNotificationProgress(taskId, i+1, total)
|
||||
}
|
||||
}
|
||||
if (failedItem == null) {
|
||||
@ -240,7 +457,7 @@ class FileOperationService : Service() {
|
||||
failedItem = folderPath
|
||||
break
|
||||
} else {
|
||||
updateNotificationProgress(notification, toMove.size+i+1, total)
|
||||
updateNotificationProgress(taskId, toMove.size+i+1, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -252,7 +469,7 @@ class FileOperationService : Service() {
|
||||
encryptedVolume: EncryptedVolume,
|
||||
dstPaths: List<String>,
|
||||
uris: List<Uri>,
|
||||
notification: FileOperationNotification,
|
||||
taskId: Int,
|
||||
): String? {
|
||||
var failedIndex = -1
|
||||
for (i in dstPaths.indices) {
|
||||
@ -265,7 +482,7 @@ class FileOperationService : Service() {
|
||||
failedIndex = i
|
||||
}
|
||||
if (failedIndex == -1) {
|
||||
updateNotificationProgress(notification, i+1, dstPaths.size)
|
||||
updateNotificationProgress(taskId, i+1, dstPaths.size)
|
||||
} else {
|
||||
return uris[failedIndex].toString()
|
||||
}
|
||||
@ -274,9 +491,8 @@ class FileOperationService : Service() {
|
||||
}
|
||||
|
||||
suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
|
||||
val notification = showNotification(R.string.file_op_import_msg, dstPaths.size)
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
importFilesFromUris(encryptedVolume, dstPaths, uris, notification)
|
||||
return volumeTask(R.string.file_op_import_msg, dstPaths.size, volumeId) { taskId, encryptedVolume ->
|
||||
importFilesFromUris(encryptedVolume, dstPaths, uris, taskId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -284,8 +500,6 @@ class FileOperationService : Service() {
|
||||
* 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(
|
||||
rootSrcDir: DocumentFile,
|
||||
@ -316,36 +530,35 @@ class FileOperationService : Service() {
|
||||
rootDstPath: String,
|
||||
rootSrcDir: DocumentFile,
|
||||
): ImportDirectoryResult {
|
||||
val notification = showNotification(R.string.file_op_import_msg, null)
|
||||
val srcUris = arrayListOf<Uri>()
|
||||
return ImportDirectoryResult(volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
return ImportDirectoryResult(volumeTask(R.string.file_op_import_msg, null, volumeId) { taskId, encryptedVolume ->
|
||||
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 (!encryptedVolume.mkdir(dir)) {
|
||||
// if directory creation fails, check if it was already present
|
||||
if (!encryptedVolume.mkdir(dir) && encryptedVolume.getAttr(dir)?.type != Stat.S_IFDIR) {
|
||||
failedItem = dir
|
||||
break
|
||||
}
|
||||
}
|
||||
if (failedItem == null) {
|
||||
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, notification)
|
||||
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, taskId)
|
||||
}
|
||||
failedItem
|
||||
}, srcUris)
|
||||
}
|
||||
|
||||
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
|
||||
val notification = showNotification(R.string.file_op_wiping_msg, uris.size)
|
||||
val task = serviceScope.async(Dispatchers.IO) {
|
||||
return globalTask(R.string.file_op_wiping_msg, uris.size, { taskId ->
|
||||
var errorMsg: String? = null
|
||||
for (i in uris.indices) {
|
||||
yield()
|
||||
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
|
||||
if (errorMsg == null) {
|
||||
updateNotificationProgress(notification, i+1, uris.size)
|
||||
updateNotificationProgress(taskId, i+1, uris.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@ -354,8 +567,7 @@ class FileOperationService : Service() {
|
||||
rootFile?.delete()
|
||||
}
|
||||
errorMsg
|
||||
}
|
||||
return waitForTask(notification, task)
|
||||
})
|
||||
}
|
||||
|
||||
private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
|
||||
@ -391,8 +603,7 @@ class FileOperationService : Service() {
|
||||
}
|
||||
|
||||
suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
|
||||
val notification = showNotification(R.string.file_op_export_msg, items.size)
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
return volumeTask(R.string.file_op_export_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
|
||||
var failedItem: String? = null
|
||||
for (i in items.indices) {
|
||||
@ -407,7 +618,7 @@ class FileOperationService : Service() {
|
||||
}
|
||||
}
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(notification, i+1, items.size)
|
||||
updateNotificationProgress(taskId, i+1, items.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@ -436,8 +647,7 @@ class FileOperationService : Service() {
|
||||
}
|
||||
|
||||
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
|
||||
val notification = showNotification(R.string.file_op_delete_msg, items.size)
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
return volumeTask(R.string.file_op_delete_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||
var failedItem: String? = null
|
||||
for ((i, element) in items.withIndex()) {
|
||||
yield()
|
||||
@ -447,7 +657,7 @@ class FileOperationService : Service() {
|
||||
failedItem = element.fullPath
|
||||
}
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(notification, i + 1, items.size)
|
||||
updateNotificationProgress(taskId, i + 1, items.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@ -456,13 +666,13 @@ class FileOperationService : Service() {
|
||||
}.failedItem // treat cancellation as success
|
||||
}
|
||||
|
||||
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int {
|
||||
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, scope)
|
||||
count += recursiveCountChildElements(child)
|
||||
}
|
||||
}
|
||||
return count
|
||||
@ -472,9 +682,8 @@ class FileOperationService : Service() {
|
||||
src: DocumentFile,
|
||||
dst: DocumentFile,
|
||||
dstRootDirectory: ObjRef<DocumentFile?>?,
|
||||
notification: FileOperationNotification,
|
||||
taskId: Int,
|
||||
total: Int,
|
||||
scope: CoroutineScope,
|
||||
progress: ObjRef<Int> = ObjRef(0)
|
||||
): DocumentFile? {
|
||||
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
|
||||
@ -491,10 +700,10 @@ class FileOperationService : Service() {
|
||||
inputStream.close()
|
||||
if (written != child.length()) return child
|
||||
} else {
|
||||
recursiveCopyVolume(child, dstDir, null, notification, total, scope, progress)?.let { return it }
|
||||
recursiveCopyVolume(child, dstDir, null, taskId, total, progress)?.let { return it }
|
||||
}
|
||||
progress.value++
|
||||
updateNotificationProgress(notification, progress.value, total)
|
||||
updateNotificationProgress(taskId, progress.value, total)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@ -502,13 +711,14 @@ class FileOperationService : Service() {
|
||||
class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
|
||||
|
||||
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
|
||||
val notification = showNotification(R.string.copy_volume_notification, null)
|
||||
val dstRootDirectory = ObjRef<DocumentFile?>(null)
|
||||
val task = serviceScope.async(Dispatchers.IO) {
|
||||
val total = recursiveCountChildElements(src, this)
|
||||
updateNotificationProgress(notification, 0, total)
|
||||
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this)
|
||||
}
|
||||
return CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
|
||||
val result = globalTask(R.string.copy_volume_notification, null, { taskId ->
|
||||
val total = recursiveCountChildElements(src)
|
||||
updateNotificationProgress(taskId, 0, total)
|
||||
recursiveCopyVolume(src, dst, dstRootDirectory, taskId, total)
|
||||
}, {
|
||||
dstRootDirectory.value?.delete()
|
||||
})
|
||||
return CopyVolumeResult(result, dstRootDirectory.value)
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
package sushi.hardcore.droidfs.file_operations
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class NotificationBroadcastReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == FileOperationService.ACTION_CANCEL){
|
||||
intent.getBundleExtra("bundle")?.let { bundle ->
|
||||
(bundle.getBinder("binder") as FileOperationService.LocalBinder?)?.let { binder ->
|
||||
val notificationId = bundle.getInt("notificationId")
|
||||
val service = binder.getService()
|
||||
service.cancelOperation(notificationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -14,14 +14,17 @@ 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.R
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.IntentUtils
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import sushi.hardcore.droidfs.util.finishOnClose
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
|
||||
abstract class FileViewerActivity: BaseActivity() {
|
||||
@ -30,9 +33,8 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
private lateinit var originalParentPath: String
|
||||
private lateinit var windowInsetsController: WindowInsetsControllerCompat
|
||||
private var windowTypeMask = 0
|
||||
private var foldersFirst = true
|
||||
private var wasMapped = false
|
||||
protected val mappedPlaylist = mutableListOf<ExplorerElement>()
|
||||
protected val playlist = mutableListOf<ExplorerElement>()
|
||||
private val playlistMutex = Mutex()
|
||||
protected var currentPlaylistIndex = -1
|
||||
private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
|
||||
|
||||
@ -40,8 +42,10 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
filePath = intent.getStringExtra("path")!!
|
||||
originalParentPath = PathUtils.getParentPath(filePath)
|
||||
encryptedVolume = IntentUtils.getParcelableExtra(intent, "volume")!!
|
||||
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
|
||||
intent.getIntExtra("volumeId", -1)
|
||||
)!!
|
||||
finishOnClose(encryptedVolume)
|
||||
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
|
||||
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
|
||||
windowTypeMask = typeMask
|
||||
@ -126,58 +130,60 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
protected fun createPlaylist() {
|
||||
if (!wasMapped){
|
||||
encryptedVolume.recursiveMapFiles(originalParentPath)?.let { elements ->
|
||||
for (e in elements) {
|
||||
if (e.isRegularFile) {
|
||||
if (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) {
|
||||
mappedPlaylist.add(e)
|
||||
}
|
||||
}
|
||||
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 foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||
ExplorerElement.sortBy(sortOrder, foldersFirst, playlist)
|
||||
currentPlaylistIndex = playlist.indexOfFirst { it.fullPath == filePath }
|
||||
}
|
||||
}
|
||||
wasMapped = true
|
||||
}
|
||||
}
|
||||
|
||||
protected fun playlistNext(forward: Boolean) {
|
||||
private fun updateCurrentItem() {
|
||||
filePath = playlist[currentPlaylistIndex].fullPath
|
||||
}
|
||||
|
||||
protected suspend fun playlistNext(forward: Boolean) {
|
||||
createPlaylist()
|
||||
currentPlaylistIndex = if (forward) {
|
||||
(currentPlaylistIndex+1)%mappedPlaylist.size
|
||||
(currentPlaylistIndex + 1).mod(playlist.size)
|
||||
} else {
|
||||
var x = (currentPlaylistIndex-1)%mappedPlaylist.size
|
||||
if (x < 0) {
|
||||
x += mappedPlaylist.size
|
||||
(currentPlaylistIndex - 1).mod(playlist.size)
|
||||
}
|
||||
x
|
||||
}
|
||||
filePath = mappedPlaylist[currentPlaylistIndex].fullPath
|
||||
updateCurrentItem()
|
||||
}
|
||||
|
||||
protected fun refreshPlaylist() {
|
||||
mappedPlaylist.clear()
|
||||
wasMapped = false
|
||||
createPlaylist()
|
||||
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 goBackToExplorer() {
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (encryptedVolume.isClosed()) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,10 +12,12 @@ import android.widget.Toast
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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.R
|
||||
import sushi.hardcore.droidfs.databinding.ActivityImageViewerBinding
|
||||
@ -105,17 +107,15 @@ class ImageViewer: FileViewerActivity() {
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.warning)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
createPlaylist() //be sure the playlist is created before deleting if there is only one image
|
||||
if (encryptedVolume.deleteFile(filePath)) {
|
||||
playlistNext(true)
|
||||
refreshPlaylist()
|
||||
if (mappedPlaylist.size == 0) { //deleted all images of the playlist
|
||||
lifecycleScope.launch {
|
||||
if (deleteCurrentFile()) {
|
||||
if (playlist.size == 0) { // no more image left
|
||||
goBackToExplorer()
|
||||
} else {
|
||||
loadImage(true)
|
||||
}
|
||||
} else {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
CustomAlertDialogBuilder(this@ImageViewer, theme)
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.remove_failed, fileName))
|
||||
@ -123,6 +123,7 @@ class ImageViewer: FileViewerActivity() {
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setMessage(getString(R.string.single_delete_confirm, fileName))
|
||||
.show()
|
||||
@ -199,6 +200,7 @@ class ImageViewer: FileViewerActivity() {
|
||||
}
|
||||
|
||||
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false) {
|
||||
lifecycleScope.launch {
|
||||
playlistNext(deltaX < 0)
|
||||
loadImage(true)
|
||||
if (slideshowActive) {
|
||||
@ -208,6 +210,7 @@ class ImageViewer: FileViewerActivity() {
|
||||
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSlideshow(){
|
||||
slideshowActive = false
|
||||
|
@ -2,6 +2,7 @@ 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
|
||||
@ -11,6 +12,7 @@ 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 sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
@ -39,12 +41,16 @@ abstract class MediaPlayer: FileViewerActivity() {
|
||||
private fun initializePlayer(){
|
||||
player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
|
||||
bindPlayer(player)
|
||||
player.addMediaSource(createMediaSource(filePath))
|
||||
lifecycleScope.launch {
|
||||
createPlaylist()
|
||||
for (e in mappedPlaylist) {
|
||||
player.addMediaSource(createMediaSource(e.fullPath))
|
||||
playlist.forEachIndexed { index, e ->
|
||||
if (index != currentPlaylistIndex) {
|
||||
player.addMediaSource(index, 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) {
|
||||
@ -67,11 +73,13 @@ abstract class MediaPlayer: FileViewerActivity() {
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
if (player.repeatMode != Player.REPEAT_MODE_ONE) {
|
||||
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % mappedPlaylist.size)
|
||||
if (player.repeatMode != Player.REPEAT_MODE_ONE && currentPlaylistIndex != -1) {
|
||||
lifecycleScope.launch {
|
||||
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % player.mediaItemCount)
|
||||
refreshFileName()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
player.prepare()
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
package sushi.hardcore.droidfs.filesystems
|
||||
|
||||
import android.os.Parcel
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
@ -101,13 +100,6 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
|
||||
}
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : this(parcel.readLong())
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) = with(parcel) {
|
||||
writeByte(CRYFS_VOLUME_TYPE)
|
||||
writeLong(fusePtr)
|
||||
}
|
||||
|
||||
override fun openFileReadMode(path: String): Long {
|
||||
return nativeOpen(fusePtr, path, 0)
|
||||
}
|
||||
|
@ -2,18 +2,21 @@ package sushi.hardcore.droidfs.filesystems
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.VolumeData
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.Observable
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
abstract class EncryptedVolume: Parcelable {
|
||||
abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
|
||||
|
||||
interface Observer {
|
||||
fun onClose()
|
||||
}
|
||||
|
||||
class InitResult(
|
||||
val errorCode: Int,
|
||||
@ -35,18 +38,6 @@ abstract class EncryptedVolume: Parcelable {
|
||||
const val GOCRYPTFS_VOLUME_TYPE: Byte = 0
|
||||
const val CRYFS_VOLUME_TYPE: Byte = 1
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of a volume.
|
||||
*
|
||||
@ -92,8 +83,6 @@ abstract class EncryptedVolume: Parcelable {
|
||||
}
|
||||
}
|
||||
|
||||
override fun describeContents() = 0
|
||||
|
||||
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
|
||||
@ -107,9 +96,14 @@ abstract class EncryptedVolume: Parcelable {
|
||||
abstract fun rmdir(path: String): Boolean
|
||||
abstract fun getAttr(path: String): Stat?
|
||||
abstract fun rename(srcPath: String, dstPath: String): Boolean
|
||||
abstract fun close()
|
||||
protected abstract fun close()
|
||||
abstract fun isClosed(): Boolean
|
||||
|
||||
fun closeVolume() {
|
||||
observers.forEach { it.onClose() }
|
||||
close()
|
||||
}
|
||||
|
||||
fun pathExists(path: String): Boolean {
|
||||
return getAttr(path) != null
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
package sushi.hardcore.droidfs.filesystems
|
||||
|
||||
import android.os.Parcel
|
||||
import android.util.Log
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
@ -100,8 +99,6 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
|
||||
}
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : this(parcel.readInt())
|
||||
|
||||
override fun openFileReadMode(path: String): Long {
|
||||
return native_open_read_mode(sessionID, path).toLong()
|
||||
}
|
||||
@ -122,11 +119,6 @@ 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)
|
||||
}
|
||||
|
110
app/src/main/java/sushi/hardcore/droidfs/util/AndroidUtils.kt
Normal file
@ -0,0 +1,110 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
app/src/main/java/sushi/hardcore/droidfs/util/Observable.kt
Normal file
@ -0,0 +1,21 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
@ -196,6 +196,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1list_1dir(JNIEnv
|
||||
jint sessionID, jstring jplain_dir) {
|
||||
const char* plain_dir = (*env)->GetStringUTFChars(env, jplain_dir, NULL);
|
||||
const size_t plain_dir_len = strlen(plain_dir);
|
||||
const char append_slash = plain_dir[plain_dir_len-1] != '/';
|
||||
GoString go_plain_dir = {plain_dir, plain_dir_len};
|
||||
|
||||
struct gcf_list_dir_return elements = gcf_list_dir(sessionID, go_plain_dir);
|
||||
@ -216,7 +217,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1list_1dir(JNIEnv
|
||||
|
||||
char* fullPath = malloc(sizeof(char) * (plain_dir_len + nameLen + 2));
|
||||
strcpy(fullPath, plain_dir);
|
||||
if (plain_dir[-2] != '/') {
|
||||
if (append_slash) {
|
||||
strcat(fullPath, "/");
|
||||
}
|
||||
strcat(fullPath, name);
|
||||
|
9
app/src/main/res/drawable/round_button_background.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="?attr/buttonBackgroundColor"/>
|
||||
<corners android:radius="50dp"/>
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
39
app/src/main/res/layout/file_system_radio.xml
Normal file
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:paddingVertical="@dimen/selectable_row_vertical_padding">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="10dp"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_toEndOf="@+id/radio"
|
||||
android:layout_alignParentEnd="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="@dimen/title_text_size"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/details"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/textColorSecondary"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
@ -3,25 +3,25 @@
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap">
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/volume_type_label"/>
|
||||
android:text="@string/volume_type_label"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinner_volume_type"
|
||||
android:layout_width="wrap_content"
|
||||
<RadioGroup
|
||||
android:id="@+id/radio_group_filesystems"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginVertical="@dimen/volume_operation_vertical_gap"/>
|
||||
android:layout_marginVertical="10dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/password_label"/>
|
||||
android:text="@string/password_label"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_password"
|
||||
@ -30,13 +30,15 @@
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1"
|
||||
android:autofillHints="password"
|
||||
android:hint="@string/password"/>
|
||||
android:hint="@string/password"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/password_confirmation_label"
|
||||
android:layout_marginTop="@dimen/volume_operation_vertical_gap"/>
|
||||
android:layout_marginTop="@dimen/volume_operation_vertical_gap"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_password_confirm"
|
||||
@ -45,11 +47,13 @@
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1"
|
||||
android:autofillHints="password"
|
||||
android:hint="@string/password_confirmation_hint"/>
|
||||
android:hint="@string/password_confirmation_hint"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||
android:layout_marginVertical="@dimen/volume_operation_vertical_gap">
|
||||
|
||||
<TextView
|
||||
|
@ -13,6 +13,7 @@
|
||||
android:focusable="true"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:paddingHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||
android:paddingVertical="@dimen/selectable_row_vertical_padding"
|
||||
android:layout_marginBottom="@dimen/volume_operation_vertical_gap">
|
||||
|
||||
<ImageView
|
||||
@ -54,44 +55,24 @@
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/volume_path_label"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||
android:text="@string/volume_path_label"
|
||||
android:layout_marginBottom="10dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_volume_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="0.5"
|
||||
android:inputType="text"
|
||||
android:maxLines="1"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||
android:hint="@string/volume_path_hint"
|
||||
android:importantForAutofill="no"
|
||||
android:hint="@string/volume_path_hint"/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/button_pick_directory"
|
||||
android:layout_width="@dimen/image_button_size"
|
||||
android:layout_height="@dimen/image_button_size"
|
||||
android:scaleType="fitCenter"
|
||||
android:background="#00000000"
|
||||
android:src="@drawable/icon_folder"
|
||||
android:contentDescription="@string/pick_directory" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
android:inputType="textNoSuggestions"
|
||||
android:maxLines="1"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_warning"
|
||||
@ -101,12 +82,40 @@
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_pick_directory"
|
||||
android:layout_width="wrap_content"
|
||||
style="@style/RoundButton"
|
||||
android:drawableStart="@drawable/icon_folder_search"
|
||||
android:text="@string/pick_directory"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_marginBottom="10dp"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_or"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:text="@string/or"
|
||||
android:layout_marginBottom="10dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/button_enter_path"
|
||||
android:layout_width="wrap_content"
|
||||
style="@style/RoundButton"
|
||||
android:drawableStart="@drawable/icon_edit"
|
||||
android:text="@string/enter_volume_path"
|
||||
android:layout_gravity="center_horizontal"/>
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switch_remember"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/remember_volume"
|
||||
android:checked="true"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_gravity="center"/>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatButton
|
||||
@ -116,6 +125,7 @@
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_button_horizontal_margin"
|
||||
android:layout_marginTop="@dimen/volume_operation_vertical_gap"
|
||||
android:visibility="gone"
|
||||
android:text="@string/create_volume" />
|
||||
|
||||
</LinearLayout>
|
@ -1,24 +1,4 @@
|
||||
<resources>
|
||||
<string-array name="gocryptfs_encryption_ciphers">
|
||||
<item>AES-GCM</item>
|
||||
<item>XChaCha20-Poly1305</item>
|
||||
<item>@string/auto</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="cryfs_encryption_ciphers">
|
||||
<item>xchacha20-poly1305</item>
|
||||
<item>aes-256-gcm</item>
|
||||
<item>aes-128-gcm</item>
|
||||
<item>twofish-256-gcm</item>
|
||||
<item>twofish-128-gcm</item>
|
||||
<item>serpent-256-gcm</item>
|
||||
<item>serpent-128-gcm</item>
|
||||
<item>cast-256-gcm</item>
|
||||
<item>mars-448-gcm</item>
|
||||
<item>mars-256-gcm</item>
|
||||
<item>mars-128-gcm</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="sort_orders_entries">
|
||||
<item>اسم</item>
|
||||
<item>حجم</item>
|
||||
|
@ -32,8 +32,8 @@
|
||||
<string name="storage_perm_denied_msg">إن DroidFS لا يمكنه العمل بدون صلاحبات التخزين.</string>
|
||||
<string name="get_size_failed">لقد فشلت عملية استرداد حجم الملف.</string>
|
||||
<string name="parent_folder">المجلد الأصلي</string>
|
||||
<string name="enter_volume_path">رجاءاً أدخل مسار المجلد المشفر</string>
|
||||
<string name="enter_volume_name">رجاءاً أدخل اسم المجلد المشفر</string>
|
||||
<string name="empty_volume_path">رجاءاً أدخل مسار المجلد المشفر</string>
|
||||
<string name="empty_volume_name">رجاءاً أدخل اسم المجلد المشفر</string>
|
||||
<string name="external_open">فتح بتطبيق خارجي</string>
|
||||
<string name="single_delete_confirm">هل أنت متأكد من حذف %s ?</string>
|
||||
<string name="multiple_delete_confirm">هل أنت متأكد من حذف هذه %s العناصر ?</string>
|
||||
@ -73,7 +73,6 @@
|
||||
<string name="usf_screenshot">السماح بلقطة شاشة</string>
|
||||
<string name="usf_fingerprint">السماح بحفظ تجزئة كلمة المرور باستخدام بصمة الإصبع</string>
|
||||
<string name="usf_volume_management">إدارة مجلد التشفير</string>
|
||||
<string name="usf_keep_open">إبقاء مجلد التشفير مفتوحاً عند الخروج من التطبيق</string>
|
||||
<string name="unsafe_features">الميزات غير الآمنة</string>
|
||||
<string name="manage_unsafe_features">إدارة الميزات غير الآمنة</string>
|
||||
<string name="manage_unsafe_features_summary">تمكين / تعطيل الميزات غير الآمنة</string>
|
||||
|
@ -33,8 +33,8 @@
|
||||
<string name="storage_perm_denied_msg">DroidFS kann ohne Speicherberechtigung nicht funktionieren</string>
|
||||
<string name="get_size_failed">Fehlgeschlagen beim Abrufen der Dateigröße.</string>
|
||||
<string name="parent_folder">Übergeordneter Ordner</string>
|
||||
<string name="enter_volume_path">Bitte geben Sie den Volume-Pfad ein</string>
|
||||
<string name="enter_volume_name">Bitte geben Sie den Datenträgernamen ein</string>
|
||||
<string name="empty_volume_path">Bitte geben Sie den Volume-Pfad ein</string>
|
||||
<string name="empty_volume_name">Bitte geben Sie den Datenträgernamen ein</string>
|
||||
<string name="external_open">Öffnen mit externer Anwendung</string>
|
||||
<string name="single_delete_confirm">Sind Sie sicher, dass Sie %s löschen wollen?</string>
|
||||
<string name="multiple_delete_confirm">Sind Sie sicher, dass Sie diese %s Elemente löschen wollen?</string>
|
||||
@ -74,7 +74,6 @@
|
||||
<string name="usf_screenshot">Screenshots zulassen</string>
|
||||
<string name="usf_fingerprint">Kennwort-Hash mit Fingerabdruck speichern können</string>
|
||||
<string name="usf_volume_management">Volumenverwaltung</string>
|
||||
<string name="usf_keep_open">Volumen offen halten, wenn die App in den Hintergrund geht</string>
|
||||
<string name="unsafe_features">Unsichere Funktionen</string>
|
||||
<string name="manage_unsafe_features">Sichere Funktionen verwalten</string>
|
||||
<string name="manage_unsafe_features_summary">Aktivieren/Deaktivieren unsicherer Funktionen</string>
|
||||
|
@ -33,8 +33,8 @@
|
||||
<string name="storage_perm_denied_msg">DroidFS no puede funcionar sin permisos de almacenamiento.</string>
|
||||
<string name="get_size_failed">No se ha podido recuperar el tamaño del archivo.</string>
|
||||
<string name="parent_folder">Carpeta superior</string>
|
||||
<string name="enter_volume_path">Por favor, introduce la ruta del volumen</string>
|
||||
<string name="enter_volume_name">Por favor, introduce el nombre del volumen</string>
|
||||
<string name="empty_volume_path">Por favor, introduce la ruta del volumen</string>
|
||||
<string name="empty_volume_name">Por favor, introduce el nombre del volumen</string>
|
||||
<string name="external_open">Abrir con una aplicación externa</string>
|
||||
<string name="single_delete_confirm">¿Estás seguro de que quieres borrar %s ?</string>
|
||||
<string name="multiple_delete_confirm">¿Estás seguro de que quiere borrar %s objetos?</string>
|
||||
@ -74,7 +74,6 @@
|
||||
<string name="usf_screenshot">Permitir capturas de pantalla</string>
|
||||
<string name="usf_fingerprint">Permitir guardar el hash de la contraseña mediante la huella dactilar</string>
|
||||
<string name="usf_volume_management">Gestión del volumen</string>
|
||||
<string name="usf_keep_open">Mantener el volumen abierto cuando la aplicación está en segundo plano</string>
|
||||
<string name="unsafe_features">Características inseguras</string>
|
||||
<string name="manage_unsafe_features">Gestionar las características inseguras</string>
|
||||
<string name="manage_unsafe_features_summary">Activar/desactivar funciones inseguras</string>
|
||||
|
52
app/src/main/res/values-iw/arrays.xml
Normal file
@ -0,0 +1,52 @@
|
||||
<resources>
|
||||
<string-array name="sort_orders_entries">
|
||||
<item>שם</item>
|
||||
<item>גודל</item>
|
||||
<item>תאריך</item>
|
||||
<item>שם (בסדר יורד)</item>
|
||||
<item>גודל (בסדר יורד)</item>
|
||||
<item>תאריך (בסדר יורד)</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="color_names">
|
||||
<item>ירוק</item>
|
||||
<item>אדום</item>
|
||||
<item>כחול</item>
|
||||
<item>צהוב</item>
|
||||
<item>כתום</item>
|
||||
<item>סגול</item>
|
||||
<item>ורוד</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="export_methods">
|
||||
<item>אוטומטי (בהתאם לזיכרון הזמין)</item>
|
||||
<item>ייצוא זמני לאחסון הפנימי (אמין אך עשוי להשאיר עקבות)</item>
|
||||
<item>קובץ זיכרון (בטוח יותר אבל לא תמיד עובד)</item>
|
||||
</string-array>
|
||||
|
||||
<!-- don't translate the following otherwise the app will crash -->
|
||||
<string-array name="sort_orders_values">
|
||||
<item>name</item>
|
||||
<item>size</item>
|
||||
<item>date</item>
|
||||
<item>name_desc</item>
|
||||
<item>size_desc</item>
|
||||
<item>date_desc</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="color_values">
|
||||
<item>green</item>
|
||||
<item>red</item>
|
||||
<item>blue</item>
|
||||
<item>yellow</item>
|
||||
<item>orange</item>
|
||||
<item>purple</item>
|
||||
<item>pink</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="export_methods_values">
|
||||
<item>auto</item>
|
||||
<item>disk</item>
|
||||
<item>memory</item>
|
||||
</string-array>
|
||||
</resources>
|
282
app/src/main/res/values-iw/strings.xml
Normal file
@ -0,0 +1,282 @@
|
||||
<resources>
|
||||
<string name="app_name">DroidFS</string>
|
||||
<string name="create_volume">צור אמצעי אחסון</string>
|
||||
<string name="open">פתח</string>
|
||||
<string name="create">צור</string>
|
||||
<string name="change_password">שנה סיסמא</string>
|
||||
<string name="password">סיסמא</string>
|
||||
<string name="import_files">ייבוא/להצפין קבצים</string>
|
||||
<string name="import_folder">ייבוא/להצפין תיקיות</string>
|
||||
<string name="discovering_files">סורק קבצים..</string>
|
||||
<string name="mkdir">צור תיקייה</string>
|
||||
<string name="dir_empty">ספריה ריקה</string>
|
||||
<string name="warning">אזהרה !</string>
|
||||
<string name="ask_lock_volume">האם אתה בטוח שברצונך לנעול אמצעי אחסון זה?</string>
|
||||
<string name="ok">בסדר</string>
|
||||
<string name="cancel">ביטול</string>
|
||||
<string name="enter_folder_name">שם תיקייה:</string>
|
||||
<string name="error">שגיאה</string>
|
||||
<string name="error_filename_empty">.נא להזין שם</string>
|
||||
<string name="error_mkdir">יצירת תיקייה נכשלה.</string>
|
||||
<string name="success_import">יובא בהצלחה!</string>
|
||||
<string name="success_import_msg">הקבצים שנבחרו יובאו בהצלחה.</string>
|
||||
<string name="import_failed">ייבוא של %s נכשל.</string>
|
||||
<string name="export_failed">ייצוא של %s נכשל.</string>
|
||||
<string name="success_export">יוצא בהצלחה!</string>
|
||||
<string name="remove_failed">המחיקה של %s נכשלה.</string>
|
||||
<string name="passwords_mismatch">סיסמאות לא תואמות</string>
|
||||
<string name="dir_not_empty">הספריה שנבחרה אינה ריקה</string>
|
||||
<string name="create_volume_failed">יצירת אמצעי אחסון נכשלה</string>
|
||||
<string name="open_volume_failed">פתיחה נכשלה</string>
|
||||
<string name="share_chooser">שתף קובץ</string>
|
||||
<string name="storage_perm_denied">הרשאת אחסון נדחתה</string>
|
||||
<string name="storage_perm_denied_msg">DroidFS לא יכול לעבוד ללא הרשאת אחסון.</string>
|
||||
<string name="get_size_failed">אחזור גודל הקובץ נכשל.</string>
|
||||
<string name="parent_folder">תיקיית אב</string>
|
||||
<string name="empty_volume_path">אנא הזן נתיב לאמצעי אחסון</string>
|
||||
<string name="empty_volume_name">הזן שם לאמצעי אחסון</string>
|
||||
<string name="external_open">פתח עם אפליקציה חיצונית</string>
|
||||
<string name="single_delete_confirm">האם אתה בטוח שברצונך למחוק %s ?</string>
|
||||
<string name="multiple_delete_confirm">האם אתה בטוח שברצנך למחוק פריטים אלה? %s </string>
|
||||
<string name="location">מיקום: %s</string>
|
||||
<string name="total_size">משקל כולל: %s</string>
|
||||
<string name="import_from_other_volume">ייבא מאמצעי אחסון אחר</string>
|
||||
<string name="read_file_failed">נכשל לפתוח את הקובץ הזה.</string>
|
||||
<string name="volume">אמצעי אחסון: %s</string>
|
||||
<string name="yes">כן</string>
|
||||
<string name="no">לא</string>
|
||||
<string name="ask_for_wipe">האם ברצונך למחוק את הקבצים המקוריים?</string>
|
||||
<string name="wipe_failed">המחיקה נכשלה: %s</string>
|
||||
<string name="wipe_successful">הקבצים נמחקו בהצלחה!</string>
|
||||
<string name="rename">שנה שם</string>
|
||||
<string name="rename_title">שם חדש:</string>
|
||||
<string name="rename_failed">שינוי השם נכשל</string>
|
||||
<string name="sort_order">מיין לפי:</string>
|
||||
<string name="change_password_failed">הפעולה נכשלה. אנא בדוק את הסיסמה הישנה שלך.</string>
|
||||
<string name="share_menu_label">הצפן עם DroidFS</string>
|
||||
<string name="share_intent_parsing_failed">בקשת השיתוף נכשלה</string>
|
||||
<string name="listdir_null_error_msg">אין אפשרות לגשת לספריה זו</string>
|
||||
<string name="fingerprint_save_checkbox_text">שמור גיבוב של הסיסמא באמצעות טביעת אצבע</string>
|
||||
<string name="fingerprint_instruction">אנא גע בחיישן טביעת האצבע</string>
|
||||
<string name="illegal_block_size_exception">הרחבת גודל בלוק לא חוקית</string>
|
||||
<string name="illegal_block_size_exception_msg">זה יכול לקרות אם הוספת טביעת אצבע חדשה, איפוס אחסון מגובב יכול לפתור את זה.</string>
|
||||
<string name="reset_hash_storage">אפס אחסון מגובב</string>
|
||||
<string name="MAC_verification_failed">חתימה/אימות מאק נכשל. חנות המפתחות של אנדרואיד או הקוד המגובב שנשמר השתנו. איפוס אחסון מגובב יכול לפתור את זה.</string>
|
||||
<string name="hash_storage_reset">אפס אחסון מגובב</string>
|
||||
<string name="encrypt_action_description">מצפין ושומר גיבוב של הסיסמא</string>
|
||||
<string name="decrypt_action_description">מפענח גיבוב סיסמא.</string>
|
||||
<string name="title_activity_settings">DroidFS הגדרות</string>
|
||||
<string name="explorer">גלה</string>
|
||||
<string name="settings_title_sort_order">מיון ברירת המחדל</string>
|
||||
<string name="usf_decrypt">אפשר ייצוא קבצים/פענוח</string>
|
||||
<string name="usf_share"> אפשר שיתוף קבצים דרך תפריט השיתוף של אנדרואיד </string>
|
||||
<string name="usf_open">אפשר פתיחת קבצים עם יישומים אחרים </string>
|
||||
<string name="usf_screenshot">אפשר צילומי מסך</string>
|
||||
<string name="usf_fingerprint">אפשר שימרת גיבוב של הסיסמא באמצעות טביעת אצבע</string>
|
||||
<string name="usf_volume_management">הגדרת אמצעי אחסון</string>
|
||||
<string name="usf_keep_open">השאר את האמצעי אחסון פתוח כשהאפליקציה רצה ברקע</string>
|
||||
<string name="unsafe_features">פיצרים לא בטוחים</string>
|
||||
<string name="manage_unsafe_features">נהל פיצרים לא בטוחים</string>
|
||||
<string name="manage_unsafe_features_summary">אפשר/מנע פיצרים לא בטוחים</string>
|
||||
<string name="usf_home_warning_msg">DroidFS מנסה להיות מאובטח ככל האפשר,עם זאת אבטחה כרוכה לעיתים קרובת בחוסר נוחות. זה הסיבה ש DroidFS מציע פיצרים לא בטוחים שאתה יכול להפעיל /להשבית בהתאם לצרכים שלך .\n\n אזהרה: פיצרים אלה יכולים להיות לא בטוחים. אל תשתמש בהם אלא אם כן אתה יודע בידיוק מה אתה עושה. מומלץ מאוד לקרוא את הקובץ דוקמנטציה לפני שמפעילים אותם.</string>
|
||||
<string name="see_unsafe_features">הראה פיצרים לא בטוחים</string>
|
||||
<string name="open_as">פתח כ</string>
|
||||
<string name="image">תמונה</string>
|
||||
<string name="video">וידאו</string>
|
||||
<string name="audio">שמע</string>
|
||||
<string name="playing_failed">נכשל לנגן את הקובץ הזה: %s</string>
|
||||
<string name="text">טקסט</string>
|
||||
<string name="save_failed">השמירה נכשלה</string>
|
||||
<string name="file_saved">קובץ נשמר!</string>
|
||||
<string name="ask_save">הקובץ מכיל שינויים שלא נשמרו. האם ברצונך לשמור אותם לפני היציאה?</string>
|
||||
<string name="save">שמור</string>
|
||||
<string name="discard">אל תשמור</string>
|
||||
<string name="word_wrap">התחל שורה משמאל לשפה האנגלית</string>
|
||||
<string name="outofmemoryerror_msg">OutOfMemoryError: הקובץ גדול מדי כדי לטעון אותו בזכרון.</string>
|
||||
<string name="new_file">צור קובץ חדש</string>
|
||||
<string name="enter_file_name">שם הקובץ:</string>
|
||||
<string name="file_creation_failed">נכשל ליצור קובץ חדש.</string>
|
||||
<string name="loading">טוען</string>
|
||||
<string name="loading_msg_create">יוצר אמצעי אחסון…</string>
|
||||
<string name="loading_msg_change_password">משנה סיסמא…</string>
|
||||
<string name="loading_msg_open">פותח אמצעי אחסון…</string>
|
||||
<string name="loading_msg_export">מייצא קבצים…</string>
|
||||
<string name="query_cursor_null_error_msg">לא הצלחתי לגשת לקובץ הזה.</string>
|
||||
<string name="about">אודות</string>
|
||||
<string name="github">GitHub</string>
|
||||
<string name="github_summary">מאגר DroidFS ב- GitHub. קוד מקור, תיעוד, מעקב אחר באגים…</string>
|
||||
<string name="gitea">Gitea</string>
|
||||
<string name="gitea_summary">מאגר DroidFS בChapril Gitea. שלא כמו GitHub, Gitea היא תוכנה חינמית לחלוטין ומתארחת בעצמה. קוד מקור, תיעוד, מעקב אחר באגים…</string>
|
||||
<string name="share">שתף</string>
|
||||
<string name="decrypt_files">ייצא/פענח</string>
|
||||
<string name="copy_failed">העתקת %s נכשלה.</string>
|
||||
<string name="copy_success">העתקה בוצע בהצלחה.</string>
|
||||
<string name="add">הוסף</string>
|
||||
<string name="camera">מצלמה</string>
|
||||
<string name="picture_save_success">התמונה נשמרה ל %s</string>
|
||||
<string name="picture_save_failed">נכשל לשמור את התמונה הזאת.</string>
|
||||
<string name="video_save_success">הסרטון נשמר ל %s</string>
|
||||
<string name="file_overwrite_question">%s כבר קיים, האם ברצונך להחליף אותו?</string>
|
||||
<string name="dir_overwrite_question">%s כבר קיים, האם ברצונך למזג את התוכן שלו ?</string>
|
||||
<string name="enter_new_name">הזן שם חדש</string>
|
||||
<string name="copy_menu_title">העתק</string>
|
||||
<string name="move_failed">העברת %s נכשלה.</string>
|
||||
<string name="move_success">העברה בוצעה בהצלחה!</string>
|
||||
<string name="enter_timer_duration">הזן את משך זמן הטיימר (בשניות)</string>
|
||||
<string name="path_error">לא הצלחתי לגשת לנתיב הזה.</string>
|
||||
<string name="create_cant_write_error_msg">לDroidFS אין הרשאת כתיבה במיקום הזה, תנסה מיקום אחר</string>
|
||||
<string name="add_cant_write_warning">ל- DroidFS אין הרשאת כתיבה לנתיב זה. מוסיף אמצעי אחסון עם הרשאת קריאה בלבד.</string>
|
||||
<string name="sdcard_error_header">DroidFS יכול לכתוב רק לכרטיסי SD נשלפים תחת:</string>
|
||||
<string name="sdcard_error_add_footer">מוסיף אמצעי אחסון עם הרשאת קריאה בלבד.</string>
|
||||
<string name="sdcard_error_create_footer">אנא השתמש בספריית משנה של נתיב זה או באחסון פנימי.</string>
|
||||
<string name="slideshow_stopped">הצגת השקופיות הופסקה</string>
|
||||
<string name="slideshow_started">התחל מצגת</string>
|
||||
<string name="ask_save_img_rotated">התמונה סובבה. האם ברצונך לשמור את השינויים הללו ולדרוס את התמונה המקורית?</string>
|
||||
<string name="image_saved_successfully">השינויים בתמונה נשמרו בהצלחה.</string>
|
||||
<string name="bitmap_compress_failed">דחיסת מפת הסיביות נכשלה.</string>
|
||||
<string name="file_write_failed">כתיבת הקובץ נכשלה.</string>
|
||||
<string name="error_not_a_volume">אמצעי אחסון מוצפן לא זוהה. אנא בדוק את הנתיב שנבחר.</string>
|
||||
<string name="version">גרסא</string>
|
||||
<string name="error_cipher_null">שגיאה: הסיסמא ריקה.</string>
|
||||
<string name="key_permanently_invalidated_exception">KeyPermanentlyInvalidatedException</string>
|
||||
<string name="key_permanently_invalidated_exception_msg">נראה שהוספת טביעת אצבע חדשה. הגיבוב של סיסמאות שמורות הפך לבלתי שמיש.</string>
|
||||
<string name="usf_read_doc">עליך לקרוא אותו בעיון לפני הפעלת כל אחת מהאפשרויות הללו.</string>
|
||||
<string name="usf_doc">דוקמנטציה של פיצרים לא בטוחים</string>
|
||||
<string name="error_retrieving_filename">לא ניתן לאחזר את שם הקובץ עבור הקישור: %s</string>
|
||||
<string name="hidden_volume">אמצעי אחסון נסתר</string>
|
||||
<string name="error_slash_in_name">שם האמצעי אחסון לא יכול להכיל סלאשים</string>
|
||||
<string name="hidden_volume_warning">אמצעי אחסון נסתרים מאוחסנים באחסון הפנימי של האפליקציה. אפליקציות אחרות לא יכולות לראות את אמצעי האחסון האלה ללא גישת שורש. עם זאת, אם תסיר את ההתקנה של DroidFS או תנקה את נתוני האפליקציה, כל אמצעי האחסון הנסתרים שלך יאבדו. הקפד לבצע גיבויים!</string>
|
||||
<string name="camera_perm_needed">הרשאת מצלמה נדרשת כדי לצלם תמונות</string>
|
||||
<string name="choose_resolution">בחר רזולוציה</string>
|
||||
<string name="file_operations">פעולות קובץ</string>
|
||||
<string name="file_op_copy_msg">מעתיק קבצים…</string>
|
||||
<string name="file_op_import_msg">מייבא קבצים…</string>
|
||||
<string name="file_op_export_msg">מייצא קבצים…</string>
|
||||
<string name="file_op_move_msg">מעביר קבצים…</string>
|
||||
<string name="file_op_wiping_msg">מוחק קבצים…</string>
|
||||
<string name="folders_first">תיקיות קודם</string>
|
||||
<string name="folders_first_summary">הראה תיקיות בתחילת הרשימה</string>
|
||||
<string name="auto_fit_title">סיבוב אוטומטי של מסך נגן מדיה</string>
|
||||
<string name="auto_fit_summary">סובב אוטומטית את המסך כך שיתאים למידות הווידאו</string>
|
||||
<string name="open_tree_failed">לא נמצא סייר קבצים. אנא התקן אחד ונסה שוב.</string>
|
||||
<string name="close_volume">סגור אמצעי אחסון</string>
|
||||
<string name="sort_by">מיין לפי</string>
|
||||
<string name="cut">גזור</string>
|
||||
<string name="map_folders">מפה תיקיות</string>
|
||||
<string name="map_folders_summary">מפה באופן רקורסיבי תיקיות כדי לחשב את הגדלים שלהן (עליך להשבית זאת בעת פתיחת אמצעי אחסון גדולים)</string>
|
||||
<string name="camera_optimization">אופטימיזציה של מצלמה</string>
|
||||
<string name="maximize_quality">איכות מקסימלית</string>
|
||||
<string name="minimize_latency">איכות מינימלית</string>
|
||||
<string name="auto">אוטומטי</string>
|
||||
<string name="encryption_cipher_label">הצפנת צופן:</string>
|
||||
<string name="theme">ערכת נושא</string>
|
||||
<string name="thumbnails">תמונות ממוזערות</string>
|
||||
<string name="thumbnails_summary">הצג תמונות וסרטונים ממוזערים</string>
|
||||
<string name="seek_seconds_forward">+%d שניות</string>
|
||||
<string name="seek_seconds_backward">-%d שניות</string>
|
||||
<string name="add_volume">הוסף אמצעי אחסון</string>
|
||||
<string name="pick_directory">בחר ספרייה</string>
|
||||
<string name="volume_alread_saved">אמצעי אחסון כבר נשמר</string>
|
||||
<string name="open_dialog_title">פותח %s:</string>
|
||||
<string name="remove">הסר</string>
|
||||
<string name="settings">הגדרות</string>
|
||||
<string name="select_all">בחר הכל</string>
|
||||
<string name="remove_fingerprint">הסר טביעת אצבע</string>
|
||||
<string name="unrecoverable_key_exception_msg">%s. לא מצליח לטעון מפתח הצפנה</string>
|
||||
<string name="unrecoverable_key_exception">UnrecoverableKeyException</string>
|
||||
<string name="delete_hidden_volume_question">%s מוסתר, האם אתה רק רוצה לשכוח את הנתיב של האמצעי אחסון או גם למחוק את כל התוכן שלו?</string>
|
||||
<string name="forget_only">שכח בלבד</string>
|
||||
<string name="delete_volume">מחק אמצעי אחסון</string>
|
||||
<string name="hidden_volume_description">אחסן את אמצעי האחסון באחסון הפנימי של DroidFS</string>
|
||||
<string name="error_is_file">שגיאה: קובץ בשם זה כבר קיים</string>
|
||||
<string name="volume_path_label">הזן נתיב לאמצעי אחסון:</string>
|
||||
<string name="volume_name_label">הזן שם לאמצעי אחסון:</string>
|
||||
<string name="volume_path_hint">נתיב אמצעי אחסון</string>
|
||||
<string name="volume_name_hint">שם אמצעי אחסון</string>
|
||||
<string name="password_label">הזן את הסיסמא של האמצעי אחסון:</string>
|
||||
<string name="password_confirmation_label">חזור שנית על הסיסמא:</string>
|
||||
<string name="password_confirmation_hint">אישור סיסמא</string>
|
||||
<string name="password_hash_saved">הגיבוב של הסיסמא נשמר</string>
|
||||
<string name="no_volumes_text">אין אמצעי אחסון שמורים, הוסף ע"י לחציה על הסימן +</string>
|
||||
<string name="fingerprint_error_msg">לא ניתן להשתמש באימות טביעת אצבע: %s.</string>
|
||||
<string name="keyguard_not_secure">מגן מקשים לא מאובטח</string>
|
||||
<string name="no_hardware">לא נמצאה חומרה מתאימה</string>
|
||||
<string name="hardware_unavailable">החומרה אינה זמינה</string>
|
||||
<string name="no_fingerprint">אין טביעת אצבע שמורה</string>
|
||||
<string name="unknown_error">שגיאה לא ידועה</string>
|
||||
<string name="biometric_error">שגיאה ביומטרית: %s</string>
|
||||
<string name="apply_to_all">החל את הבחירה הזו על כל אמצעי האחסון הנסתרים</string>
|
||||
<string name="select_volume">בחר אמצעי אחסון</string>
|
||||
<string name="current_password_label">הזן את סיסמאת אמצעי אחסון הנוכחי:</string>
|
||||
<string name="current_password_hint">סיסמא נוכחית</string>
|
||||
<string name="new_password_label">הזן את הסיסמא החדשה לאמצעי אחסון:</string>
|
||||
<string name="new_password_hint">סיסמא חדשה</string>
|
||||
<string name="new_password_confirmation_label">חזור שנית על הסיסמא החדשה:</string>
|
||||
<string name="error_marshmallow_required">הפיצר הזה זמין רק למשתמשי אנדרואיד 6 (מרשמלו) ומעלה</string>
|
||||
<string name="copy_hidden_volume">העתק לאחסון משותף</string>
|
||||
<string name="copy_external_volume">צור עותק נסתר</string>
|
||||
<string name="copy_volume_notification">מעתיק אמצעי אחסון…</string>
|
||||
<string name="hidden_volume_already_exists">אמצעי אחסון נסתר עם אותו שם כבר קיים.</string>
|
||||
<string name="pdf_document">מסמך PDF</string>
|
||||
<string name="thumbnail_max_size">גודל מקסימלי עבור תמונות ממוזערות</string>
|
||||
<string name="thumbnail_max_size_summary">גודל קובץ מקסימלי לטעינת תמונה ממוזערת. ערך נוכחי: %s</string>
|
||||
<string name="size_hint">גודל (בקילו בייט)</string>
|
||||
<string name="invalid_number">מספר לא תקין</string>
|
||||
<string name="new_volume_name">שם אמצעי אחסון חדש:</string>
|
||||
<string name="volume_rename_failed">שינוי שם אמצעי אחסון נכשל</string>
|
||||
<string name="switch_display_layout">החלף פריסת תצוגה</string>
|
||||
<string name="one_file">קובץ 1</string>
|
||||
<string name="multiple_files">%d קבצים</string>
|
||||
<string name="one_folder">1 תיקייה</string>
|
||||
<string name="multiple_folders">%d תיקיות</string>
|
||||
<string name="default_open">פתח אמצעי אחסון זה בעת הפעלת היישום</string>
|
||||
<string name="remove_default_open">אל תפתח כברירת מחדל</string>
|
||||
<string name="elements_selected">%d/%d נבחרו</string>
|
||||
<string name="pin_passwords_title">פריסת מקלדת מספרים</string>
|
||||
<string name="pin_passwords_summary">שימוש בפריסת מקלדת מספרים בעת הזנת סיסמאות אמצעי אחסון</string>
|
||||
<string name="volume_type_label">סוג אמצעי אחסון:</string>
|
||||
<string name="gocryptfs">Gocryptfs</string>
|
||||
<string name="cryfs">CryFS</string>
|
||||
<string name="gocryptfs_disabled">תמיכת Gocryptfs הושבתה</string>
|
||||
<string name="cryfs_disabled">תמיכת CryFS הושבתה</string>
|
||||
<string name="file_op_delete_msg">מוחק קבצים…</string>
|
||||
<string name="volume_type">(%s)</string>
|
||||
<string name="volume_type_read_only">(%s, קריאה בלבד)</string>
|
||||
<string name="volume_type_inaccessible">(%s, לא נגיש)</string>
|
||||
<string name="io_error">שגיאת קלט/פלט.</string>
|
||||
<string name="use_fingerprint">השתמש בטביעת אצבע במקום בסיסמה הנוכחית</string>
|
||||
<string name="remember_volume">זכור אמצעי אחסון</string>
|
||||
<string name="open_volume">פתח אמצעי אחסון</string>
|
||||
<string name="choose_existing_volume">אנא בחר אמצעי אחסון קיים</string>
|
||||
<string name="volume_unlocked">אמצעי אחסון פתוח</string>
|
||||
<string name="lock_volume">נעל אמצעי אחסון</string>
|
||||
<string name="lock">נעל</string>
|
||||
<string name="ux">חוויית משתמש</string>
|
||||
<string name="theme_color">צבע ערכת נושא</string>
|
||||
<string name="theme_color_summary">שנה את צבע הערכת נושא של היישום</string>
|
||||
<string name="black_theme">שחור</string>
|
||||
<string name="password_fallback">חזרה לסיסמא</string>
|
||||
<string name="password_fallback_summary">בקש סיסמה כאשר אימות טביעת האצבע מבוטל</string>
|
||||
<string name="unknown_error_code">קוד שגיאה לא ידוע: %d</string>
|
||||
<string name="config_load_error">לא ניתן לטעון את קובץ התצורה. ודא שיש גישה לאמצעי אחסון.</string>
|
||||
<string name="wrong_password">לא ניתן לפענח את קובץ התצורה. אנא בדוק את הסיסמה שלך.</string>
|
||||
<string name="filesystem_id_changed">מזהה מערכת הקבצים בקובץ התצורה שונה מהפעם האחרונה שפתחנו אמצעי אחסון זה. פירוש הדבר יכול להיות מפני שתוקף החליף את מערכת הקבצים במערכת אחרת.</string>
|
||||
<string name="inaccessible_base_dir">אמצעי האחסון לא קיים או שאינו נגיש.</string>
|
||||
<string name="task_failed">המשימה נכשלה: %s</string>
|
||||
<string name="usf_expose">חשוף אמצעי אחסון פתוחים</string>
|
||||
<string name="usf_expose_summary">אפשר ליישומים אחרים לעיין באמצעי אחסון פתוחים כספקי מסמכים</string>
|
||||
<string name="usf_saf_write">הענק הרשאת כתיבה</string>
|
||||
<string name="usf_saf_write_summary">הענק הרשאת כתיבה בעת פתיחת קבצים עם יישומים אחרים</string>
|
||||
<string name="saf">גישה לאחסון Framework</string>
|
||||
<string name="tmp_export_failed">הייצוא נכשל: %s</string>
|
||||
<string name="export_failed_create">לא הצלחתי ליצור קובץ ייצוא</string>
|
||||
<string name="export_failed_export">ייצוא הקובץ נכשל</string>
|
||||
<string name="export_mem">מייצא לזיכרון…</string>
|
||||
<string name="export_disk">מייצא לאחסון פנימי…</string>
|
||||
<string name="memfd_create_unsupported">הקרנל הנוכחי שלך לא תומכת ב-memfd_create(). תכונה זו דורשת גרסת קרנל מינימלי של %s.</string>
|
||||
<string name="export_method">שיטת ייצוא</string>
|
||||
<string name="export_method_summary">שיטת ייצוא קבצים. משמש לשיתוף, פתיחה חיצונית וגישה לקבצים חשופים.</string>
|
||||
<string name="debug">דיבאג</string>
|
||||
<string name="logcat_title">DroidFS Logcat</string>
|
||||
<string name="logcat_saved">Logcat נשמר</string>
|
||||
</resources>
|
@ -32,8 +32,8 @@
|
||||
<string name="storage_perm_denied_msg">DroidFS não pode funcionar sem permissão ao armazenamento.</string>
|
||||
<string name="get_size_failed">Falha na recuperação do tamanho do arquivo.</string>
|
||||
<string name="parent_folder">Pasta principal</string>
|
||||
<string name="enter_volume_path">Por favor, digite a localização do volume</string>
|
||||
<string name="enter_volume_name">Por favor, digite o nome do volume</string>
|
||||
<string name="empty_volume_path">Por favor, digite a localização do volume</string>
|
||||
<string name="empty_volume_name">Por favor, digite o nome do volume</string>
|
||||
<string name="external_open">Abrir com app externo</string>
|
||||
<string name="single_delete_confirm">Você tem certeza que quer excluir %s?</string>
|
||||
<string name="multiple_delete_confirm">Você realmente deseja excluir estos %s itens?</string>
|
||||
@ -73,7 +73,6 @@
|
||||
<string name="usf_screenshot">Permitir capturas da tela</string>
|
||||
<string name="usf_fingerprint">Permitir salvar o hash da senha usando impressão digital</string>
|
||||
<string name="usf_volume_management">Gerenciador de volumes</string>
|
||||
<string name="usf_keep_open">Mantenha o volume aberto quando o app ficar em segundo plano</string>
|
||||
<string name="unsafe_features">Opções perigosas</string>
|
||||
<string name="manage_unsafe_features">Gerenciar opções perigosas</string>
|
||||
<string name="manage_unsafe_features_summary">Alternar opções perigosas</string>
|
||||
|
@ -31,8 +31,8 @@
|
||||
<string name="storage_perm_denied_msg">DroidFS не может работать без разрешения на доступ к хранилищу.</string>
|
||||
<string name="get_size_failed">Невозможно получить размер файла.</string>
|
||||
<string name="parent_folder">Родительская папка</string>
|
||||
<string name="enter_volume_path">Введите путь тома</string>
|
||||
<string name="enter_volume_name">Введите название тома</string>
|
||||
<string name="empty_volume_path">Введите путь тома</string>
|
||||
<string name="empty_volume_name">Введите название тома</string>
|
||||
<string name="external_open">Открыть внешним приложением</string>
|
||||
<string name="single_delete_confirm">Удалить %s?</string>
|
||||
<string name="multiple_delete_confirm">Удалить %s элементов?</string>
|
||||
@ -71,7 +71,6 @@
|
||||
<string name="usf_screenshot">Разрешить снимки экрана</string>
|
||||
<string name="usf_fingerprint">Разрешить сохранение хеша пароля отпечатком пальца</string>
|
||||
<string name="usf_volume_management">Управление томом</string>
|
||||
<string name="usf_keep_open">Оставлять том открытым, когда DroidFS в фоне</string>
|
||||
<string name="unsafe_features">Небезопасные функции</string>
|
||||
<string name="manage_unsafe_features">Управление небезопасными функциями</string>
|
||||
<string name="manage_unsafe_features_summary">Включить/отключить небезопасные функции</string>
|
||||
@ -268,4 +267,17 @@
|
||||
<string name="debug">Отладка</string>
|
||||
<string name="logcat_title">Журнал logcat DroidFS</string>
|
||||
<string name="logcat_saved">Журнал logcat сохранён</string>
|
||||
<string name="later">Позже</string>
|
||||
<string name="notification_denied_msg">Разрешение на отображение уведомления не получено. Фоновые операции с файлами не будут видны. Это можно изменить в настройках разрешений приложения.</string>
|
||||
<string name="keep_alive_notification_title">Служба поддержки работы</string>
|
||||
<string name="keep_alive_notification_text">Один или несколько томов остаются открытыми.</string>
|
||||
<string name="close_all">Закрыть все</string>
|
||||
<string name="usf_background">Отключить автоблокировку томов</string>
|
||||
<string name="usf_background_summary">Не блокировать тома автоматически при работе приложения в фоновом режиме</string>
|
||||
<string name="usf_keep_open">Держать тома открытыми</string>
|
||||
<string name="usf_keep_open_summary">Поддерживать постоянную работу приложения в фоновом режиме, чтобы тома оставались открытыми</string>
|
||||
<string name="gocryptfs_details">Быстрый, но не скрывает размеры файлов и структуру папок</string>
|
||||
<string name="cryfs_details">Медленнее, но защищает метаданные и предотвращает атаки замещения</string>
|
||||
<string name="or">или</string>
|
||||
<string name="enter_volume_path">Введите путь к тому</string>
|
||||
</resources>
|
||||
|
@ -1,24 +1,4 @@
|
||||
<resources>
|
||||
<string-array name="gocryptfs_encryption_ciphers">
|
||||
<item>AES-GCM</item>
|
||||
<item>XChaCha20-Poly1305</item>
|
||||
<item>@string/auto</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="cryfs_encryption_ciphers">
|
||||
<item>xchacha20-poly1305</item>
|
||||
<item>aes-256-gcm</item>
|
||||
<item>aes-128-gcm</item>
|
||||
<item>twofish-256-gcm</item>
|
||||
<item>twofish-128-gcm</item>
|
||||
<item>serpent-256-gcm</item>
|
||||
<item>serpent-128-gcm</item>
|
||||
<item>cast-256-gcm</item>
|
||||
<item>mars-448-gcm</item>
|
||||
<item>mars-256-gcm</item>
|
||||
<item>mars-128-gcm</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="sort_orders_entries">
|
||||
<item>Ad</item>
|
||||
<item>Boyut</item>
|
||||
|
@ -33,8 +33,8 @@
|
||||
<string name="storage_perm_denied_msg">DroidFS, depolama izinleri olmadan çalışamaz.</string>
|
||||
<string name="get_size_failed">Dosya boyutu alınamadı.</string>
|
||||
<string name="parent_folder">Ana klasör</string>
|
||||
<string name="enter_volume_path">Lütfen birim yolunu girin</string>
|
||||
<string name="enter_volume_name">Lütfen birim adını girin</string>
|
||||
<string name="empty_volume_path">Lütfen birim yolunu girin</string>
|
||||
<string name="empty_volume_name">Lütfen birim adını girin</string>
|
||||
<string name="external_open">Harici uygulamayla aç</string>
|
||||
<string name="single_delete_confirm">Silmek istediğimizden emin misiniz: %s</string>
|
||||
<string name="multiple_delete_confirm">Bunları silmek istediğinizden emin misiniz: %s</string>
|
||||
@ -74,7 +74,6 @@
|
||||
<string name="usf_screenshot">Ekran görüntüsü almaya izin ver</string>
|
||||
<string name="usf_fingerprint">Parmak izi kullanılarak şifre hash değerinin kaydedilmesine izin ver</string>
|
||||
<string name="usf_volume_management">Birim yönetimi</string>
|
||||
<string name="usf_keep_open">Uygulama arka plana geçtiğinde birimi açık tutun</string>
|
||||
<string name="unsafe_features">Güvenli olmayan özellikler</string>
|
||||
<string name="manage_unsafe_features">Güvenli olmayan özellikleri yönetin</string>
|
||||
<string name="manage_unsafe_features_summary">Güvenli olmayan özellikleri etkinleştirme/devre dışı bırakma</string>
|
||||
|
@ -34,8 +34,8 @@
|
||||
<string name="storage_perm_denied_msg">没有存储权限时DroidFS无法工作</string>
|
||||
<string name="get_size_failed">无法获取文件的大小</string>
|
||||
<string name="parent_folder">上一级文件夹</string>
|
||||
<string name="enter_volume_path">输入卷的路径</string>
|
||||
<string name="enter_volume_name">输入卷的名称</string>
|
||||
<string name="empty_volume_path">输入卷的路径</string>
|
||||
<string name="empty_volume_name">输入卷的名称</string>
|
||||
<string name="external_open">使用外部软件打开</string>
|
||||
<string name="single_delete_confirm">确认删除%s?</string>
|
||||
<string name="multiple_delete_confirm">确认删除多个文件:%s</string>
|
||||
@ -75,7 +75,6 @@
|
||||
<string name="usf_screenshot">允许截屏</string>
|
||||
<string name="usf_fingerprint">允许通过指纹保存密码哈希</string>
|
||||
<string name="usf_volume_management">加密卷管理</string>
|
||||
<string name="usf_keep_open">当应用切入后台时已打开的卷不再自动锁上</string>
|
||||
<string name="unsafe_features">以下功能会降低安全性</string>
|
||||
<string name="manage_unsafe_features">管理非安全功能</string>
|
||||
<string name="manage_unsafe_features_summary">打开/关闭非安全功能</string>
|
||||
|
@ -1,8 +1,8 @@
|
||||
<resources>
|
||||
<string-array name="gocryptfs_encryption_ciphers">
|
||||
<item>@string/auto</item>
|
||||
<item>AES-GCM</item>
|
||||
<item>XChaCha20-Poly1305</item>
|
||||
<item>@string/auto</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="cryfs_encryption_ciphers">
|
||||
|
@ -12,4 +12,5 @@
|
||||
<dimen name="dialog_padding_top">10dp</dimen>
|
||||
<dimen name="dialog_text_size">16sp</dimen>
|
||||
<dimen name="title_file_name_text_size">20sp</dimen>
|
||||
<dimen name="selectable_row_vertical_padding">10dp</dimen>
|
||||
</resources>
|
@ -33,8 +33,8 @@
|
||||
<string name="storage_perm_denied_msg">DroidFS can\'t work without storage permissions.</string>
|
||||
<string name="get_size_failed">Failed to retrieve file size.</string>
|
||||
<string name="parent_folder">Parent Folder</string>
|
||||
<string name="enter_volume_path">Please enter the volume path</string>
|
||||
<string name="enter_volume_name">Please enter the volume name</string>
|
||||
<string name="empty_volume_path">Please enter the volume path</string>
|
||||
<string name="empty_volume_name">Please enter the volume name</string>
|
||||
<string name="external_open">Open with external app</string>
|
||||
<string name="single_delete_confirm">Are you sure you want to delete %s ?</string>
|
||||
<string name="multiple_delete_confirm">Are you sure you want to delete these %s items ?</string>
|
||||
@ -74,11 +74,10 @@
|
||||
<string name="usf_screenshot">Allow screenshots</string>
|
||||
<string name="usf_fingerprint">Allow saving password hash using fingerprint</string>
|
||||
<string name="usf_volume_management">Volume Management</string>
|
||||
<string name="usf_keep_open">Keep volume open when the app goes in background</string>
|
||||
<string name="unsafe_features">Unsafe Features</string>
|
||||
<string name="manage_unsafe_features">Manage unsafe features</string>
|
||||
<string name="manage_unsafe_features_summary">Enable/Disable unsafe features</string>
|
||||
<string name="usf_home_warning_msg">DroidFS try to be as secure as possible. However, security often involves lack of comfort. This is why DroidFS offer you additional unsafe features that you can enable/disable according to your needs.\n\nWarning: this features can be UNSAFE. Do not use them unless you know exactly what you are doing. It is highly recommended to read the documentation before enabling them.</string>
|
||||
<string name="usf_home_warning_msg">DroidFS aims to be as secure as possible. However, security often involves lack of comfort. This is why DroidFS offers you additional unsafe features that you can enable and disable according to your needs.\n\nWarning: this features can be UNSAFE. Do not use them unless you know exactly what you are doing. It is highly recommended to read the documentation before enabling them.</string>
|
||||
<string name="see_unsafe_features">See unsafe features</string>
|
||||
<string name="open_as">Open as</string>
|
||||
<string name="image">Image</string>
|
||||
@ -175,7 +174,7 @@
|
||||
<string name="seek_seconds_forward">+%d seconds</string>
|
||||
<string name="seek_seconds_backward">-%d seconds</string>
|
||||
<string name="add_volume">Add volume</string>
|
||||
<string name="pick_directory">Pick directory</string>
|
||||
<string name="pick_directory">Pick a directory</string>
|
||||
<string name="volume_alread_saved">Volume already saved</string>
|
||||
<string name="open_dialog_title">Opening %s:</string>
|
||||
<string name="remove">Remove</string>
|
||||
@ -279,4 +278,17 @@
|
||||
<string name="debug">Debug</string>
|
||||
<string name="logcat_title">DroidFS Logcat</string>
|
||||
<string name="logcat_saved">Logcat saved</string>
|
||||
<string name="later">Later</string>
|
||||
<string name="notification_denied_msg">Notification permission has been denied. Background file operations won\'t be visible. You can change this in the app\'s permission settings.</string>
|
||||
<string name="keep_alive_notification_title">Keep alive service</string>
|
||||
<string name="keep_alive_notification_text">One or more volumes are kept open.</string>
|
||||
<string name="close_all">Close all</string>
|
||||
<string name="usf_background">Disable volume auto-locking</string>
|
||||
<string name="usf_background_summary">Don\'t lock volumes when the app goes in background</string>
|
||||
<string name="usf_keep_open">Keep volumes open</string>
|
||||
<string name="usf_keep_open_summary">Maintain the app always running in the background to keep volumes open</string>
|
||||
<string name="gocryptfs_details">Fast, but doesn\'t hide file sizes and directory structure</string>
|
||||
<string name="cryfs_details">Slower, but protects metadata and prevents replacement attacks</string>
|
||||
<string name="or">or</string>
|
||||
<string name="enter_volume_path">Enter volume path</string>
|
||||
</resources>
|
||||
|
@ -8,7 +8,7 @@
|
||||
<item name="android:statusBarColor">@color/primary</item>
|
||||
<item name="infoBarBackgroundColor">#181818</item>
|
||||
<item name="buttonBackgroundColor">#5B5A5C</item>
|
||||
<item name="buttonStyle">@style/DarkButton</item>
|
||||
<item name="buttonStyle">@style/Button</item>
|
||||
</style>
|
||||
|
||||
<style name="DarkRed" parent="BaseTheme">
|
||||
@ -35,7 +35,6 @@
|
||||
<item name="android:navigationBarColor">@color/black</item>
|
||||
<item name="infoBarBackgroundColor">@color/black</item>
|
||||
<item name="buttonBackgroundColor">#3B3A3C</item>
|
||||
<item name="buttonStyle">@style/BlackButton</item>
|
||||
</style>
|
||||
<style name="BlackRed" parent="BlackGreen">
|
||||
<item name="colorAccent">@color/red</item>
|
||||
@ -56,11 +55,17 @@
|
||||
<item name="colorAccent">@color/pink</item>
|
||||
</style>
|
||||
|
||||
<style name="DarkButton" parent="Widget.AppCompat.Button">
|
||||
<style name="Button" parent="Widget.AppCompat.Button">
|
||||
<item name="android:background">@drawable/button_background</item>
|
||||
</style>
|
||||
<style name="BlackButton" parent="Widget.AppCompat.Button">
|
||||
<item name="android:background">@drawable/button_background</item>
|
||||
|
||||
<style name="RoundButton" parent="Widget.AppCompat.Button">
|
||||
<item name="android:background">@drawable/round_button_background</item>
|
||||
<item name="textAllCaps">false</item>
|
||||
<item name="android:layout_height">35sp</item>
|
||||
<item name="android:paddingStart">15dp</item>
|
||||
<item name="android:paddingEnd">15dp</item>
|
||||
<item name="android:drawablePadding">5dp</item>
|
||||
</style>
|
||||
|
||||
<style name="infoBarTextView">
|
||||
|
@ -48,11 +48,19 @@
|
||||
android:key="usf_fingerprint"
|
||||
android:title="@string/usf_fingerprint" />
|
||||
|
||||
<SwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:icon="@drawable/icon_lock_open"
|
||||
android:key="usf_background"
|
||||
android:title="@string/usf_background"
|
||||
android:summary="@string/usf_background_summary" />
|
||||
|
||||
<SwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:icon="@drawable/icon_lock_open"
|
||||
android:key="usf_keep_open"
|
||||
android:title="@string/usf_keep_open" />
|
||||
android:title="@string/usf_keep_open"
|
||||
android:summary="@string/usf_keep_open_summary"/>
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
|
8
fastlane/metadata/android/en-US/changelogs/374.txt
Normal file
@ -0,0 +1,8 @@
|
||||
- Reworked UI for adding volumes
|
||||
- New unsafe feature to keep the app running as a foreground service
|
||||
- Allow choosing file export method
|
||||
- Logcat viewer (for easier debugging)
|
||||
- New turkish, chinese-simplified, and hebrew translations
|
||||
- UX improvements
|
||||
- Bug fixes
|
||||
- Translations updates
|
@ -7,6 +7,7 @@ Currently, DroidFS supports the following encrypted containers:
|
||||
- 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
|
||||
- Ability to expose volumes to other applications
|
||||
- Unlocking volumes using fingerprint authentication
|
||||
- Volume auto-locking when the app goes in background
|
||||
|
||||
@ -15,6 +16,7 @@ Currently, DroidFS supports the following encrypted containers:
|
||||
<b>Biometric/Fingerprint hardware:</b> needed to encrypt/decrypt password hashes using a fingerprint protected key.
|
||||
<b>Camera:</b> required to take encrypted photos or videos directly from the app.
|
||||
<b>Record audio:</b> required if you want sound on videos recorded with DroidFS.
|
||||
<b>Notifications:</b> used to report file operations progress and notify about volumes kept open
|
||||
|
||||
All of these permissions can be denied if you don't want to use the corresponding feature.
|
||||
|
||||
|
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg
Normal file
After Width: | Height: | Size: 631 B |
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 91 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg
Normal file
After Width: | Height: | Size: 631 B |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 100 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg
Normal file
After Width: | Height: | Size: 631 B |
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 133 KiB |
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 144 KiB |
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 232 KiB |
@ -1 +1 @@
|
||||
Subproject commit 48631380a7a8b5f0932078e2b643e06c3f433890
|
||||
Subproject commit 8df8522088b095de23d4de95c73320a91b111a8d
|