Compare commits

...

55 Commits

Author SHA1 Message Date
ae0e23acc9
Ring buffer logcat viewer 2024-11-30 13:22:51 +01:00
sonntagschnee
d72cc857e2
Fix German translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-11-29 16:28:18 +01:00
82dda95211
Fix move feedback messages 2024-11-15 20:51:47 +01:00
40bed2db21
Match file viewer playlist recursivity with settings 2024-11-14 23:28:32 +01:00
f901495e41
Background file viewer playlist creation 2024-11-14 23:20:41 +01:00
07f5f8b5d9
FileOperationService: remove the correct notification helper on activity destroy 2024-11-13 11:52:42 +01:00
2d5f5a82c9
Wipe exported disk files on background threads 2024-11-11 19:32:18 +01:00
b477272d65
Close open FD after file export to avoid resource leaks 2024-11-11 17:24:35 +01:00
88bd746359
Add .avif extension 2024-10-29 11:58:43 +01:00
9872cab7c2
Add m4v extension 2024-09-24 20:16:34 +02:00
4aa211bca4
Fix gocryptfs_jni.c invalid string access 2024-08-22 23:01:35 +02:00
0a1406769b
Fix ABI versionCode offsets 2024-07-26 13:36:45 +02:00
a62f32e364
Update README.md & TODO.md 2024-07-25 17:18:29 +02:00
f865c864a2
Add changelog & Update screenshots & description 2024-07-25 17:16:57 +02:00
e804059b23
Add Hebrew translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-07-25 16:59:30 +02:00
solokot
bb821d5f41
Update Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-07-25 13:15:25 +02:00
6c0e20c68e
Disable usfExpose when disabling usfBackground 2024-07-24 17:57:20 +02:00
e9b67bd9c4
Update dependencies 2024-07-22 21:19:12 +02:00
c0dcaed8d2
Improve volume adding UI 2024-07-22 21:06:29 +02:00
85e24921fa
Replace file systems dropdown with radio buttons 2024-07-18 23:50:05 +02:00
15f288be11
Auto gocryptfs cipher by default & Fix FileOperationsService notification permission request 2024-07-18 23:50:04 +02:00
bb49501403
OpenSSL & FFmpeg as submodules & Different versionCode for each ABI 2024-07-18 23:49:51 +02:00
33d565bf22
KeepAlive foreground service 2024-07-16 15:03:44 +02:00
52a29b034c
Target Android 13 & Make FileOperationService a foreground service 2024-06-15 16:39:40 +02:00
d44601f69f
Restore upstream video player controls & Update dependencies 2024-06-10 23:39:52 +02:00
4b002c7b24
Fix SecurityException when importing from exposed volume 2024-06-07 16:07:20 +02:00
7c72c4e829
Update dependencies & Fix build 2024-06-06 21:08:11 +02:00
bd60e62635
Allow importing from ClipData 2024-06-03 16:11:27 +02:00
CyanWolf
d1e042c347
Update Spanish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-05-31 19:26:05 +02:00
sjceel
0805ebda35
Add Chinese-Simplified translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-05-31 19:11:53 +02:00
intergalacticmonkey
36e6ad99b3
Fix typo in Turkish strings.xml
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-05-23 13:49:58 +02:00
solokot
967d4551c5
Update Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-12 16:51:55 +01:00
Ali Beyaz
b747d2822a
Add Turkish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-12 16:47:30 +01:00
CyanWolf
e5652666d8
Update Spanish
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-11 18:12:17 +01:00
Muhmmad14333653
cda0e90b96
Update Arabic translations
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-11 18:08:54 +01:00
6f43bc7417
Avoid being killed by SELinux when retrieving volume path 2024-02-11 17:55:24 +01:00
c26ab661c2
Logcat activity 2024-01-30 18:29:49 +01:00
1c15f9fac8
Allow choosing export method 2024-01-28 15:44:53 +01:00
b4635dc2e0
Directory loading indicator 2024-01-13 23:19:22 +01:00
f4e47c1827
Allow directory creation on exposed volumes 2024-01-13 21:41:58 +01:00
5474d6eea5
Add .opus & Update build config 2024-01-13 21:25:31 +01:00
719faa31ee
Fix README 2023-10-15 17:09:48 +02:00
a41cde1c53
DroidFS v2.1.3 2023-09-28 19:36:55 +02:00
b503f134d5
Fix Intent.getParcelableExtra() crash on Android 13 2023-09-24 19:04:49 +02:00
3ba774fda3
Add Version.toString() 2023-09-19 13:47:59 +02:00
b2154d319e
Repair corrupted database due to v2.1.1 2023-09-19 13:39:35 +02:00
571a79cc1d
Really fix database upgrade 2023-09-19 11:41:01 +02:00
891a581329
Update dependencies 2023-09-17 20:10:15 +02:00
f1a9c1383c
Fix database upgrade 2023-09-17 19:11:52 +02:00
ac71ad887d
Fix README 2023-09-10 21:39:28 +02:00
e1fe329f49
Add v2.1.0 changelog 2023-09-10 21:11:04 +02:00
dfff597ae5
DroidFS v2.1.0 2023-09-10 21:01:39 +02:00
bd429648b3
Update documentation 2023-09-10 21:01:04 +02:00
71ff37b170
Fixes 2023-09-10 19:17:51 +02:00
4afe56b13c
Migrate to AndroidX Media3 2023-09-10 19:12:17 +02:00
121 changed files with 3205 additions and 1207 deletions

3
.gitmodules vendored
View File

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

View File

@ -1,18 +1,21 @@
# Introduction
DroidFS relies on modified versions of the original encrypted filesystems programs to open volumes. [CryFS](https://github.com/cryfs/cryfs) is written in C++ while [gocryptfs](https://github.com/rfjakob/gocryptfs) is written in [Go](https://golang.org). Thus, building DroidFS requires the compilation of native code. However, for the sake of simplicity, the application has been designed in a modular way: you can build a version of DroidFS that supports both Gocryptfs and CryFS, or only one of the two.
Moreover, DroidFS aims to be accessible to as many people as possible. If you encounter any problems or need help with the build, feel free to open an issue, a discussion, or contact me by [email](mailto:hardcore.sushi@disroot.org) or on [Matrix](https://matrix.org): @hardcoresushi:matrix.underworld.fr
Moreover, DroidFS aims to be accessible to as many people as possible. If you encounter any problems or need help with the build, feel free to open an issue, a discussion, or contact me (currently the main developer) by [email](mailto:gh@arkensys.dedyn.io) or on [Matrix](https://matrix.org): @hardcoresushi:matrix.underworld.fr
# Setup
The following two steps assume you're using a Debian-based Linux distribution. Package names might be similar for other distributions. Don't hesitate to ask if you're having trouble with this.
Install required packages:
```
$ sudo apt-get install openjdk-11-jdk-headless build-essential pkg-config git gnupg2 wget apksigner
$ sudo apt-get install openjdk-17-jdk-headless build-essential pkg-config git gnupg2 wget apksigner npm
```
You also need to manually install the [Android SDK](https://developer.android.com/studio/index.html#command-tools) and the [Android Native Development Kit (NDK)](https://developer.android.com/ndk/downloads) (r23 versions are recommended).
You also need to manually install the [Android SDK](https://developer.android.com/studio/index.html#command-tools) and the [Android Native Development Kit (NDK)](https://github.com/android/ndk/wiki/Unsupported-Downloads#r25c) version `25.2.9519653` (r25c). libcryfs cannot be built with newer NDK versions at the moment due to compatibility issues with [boost](https://www.boost.org). If you succeed in building it with a more recent version of NDK, please report it.
If you want a support for Gocryptfs volumes, you must install [Go](https://golang.org/doc/install) and libssl:
If you want a support for Gocryptfs volumes, you need to install [Go](https://golang.org/doc/install):
```
$ sudo apt-get install golang-go libssl-dev
$ sudo apt-get install golang-go
```
The code should be authenticated before being built. To verify the signatures, you will need my PGP key:
```
@ -35,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://www.openssl.org/source/openssl-1.1.1v.tar.gz
```
Verify OpenSSL signature:
```
$ wget https://www.openssl.org/source/openssl-1.1.1v.tar.gz.asc
$ gpg --verify openssl-1.1.1v.tar.gz.asc openssl-1.1.1v.tar.gz
```
Continue **ONLY** if the signature is **VALID**.
```
$ tar -xzf openssl-1.1.1v.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
@ -67,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
$ OPENSSL_PATH="./openssl-1.1.1v" ./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

View File

@ -11,7 +11,7 @@ For mortals: Encrypted storage compatible with already existing softwares.
</p>
# Support
The creator of DroidFS works as a freelance developer and privacy consultant. I am currently looking for new clients! If you are interested, take a look at the [website](https://arkensys.fr.to). Alternatively, you can directly support DroidFS by making a [donation](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/DONATE.txt).
The creator of DroidFS works as a freelance developer and privacy consultant. I am currently looking for new clients! If you are interested, take a look at the [website](https://arkensys.dedyn.io). Alternatively, you can directly support DroidFS by making a [donation](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/DONATE.txt).
Thank you so much ❤️.
@ -28,34 +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 opening files with other applications*:</h4>
Decrypt and open file using external apps. These apps could save and send the files thus opened.
</li>
<li><h4>Allow exporting files:</h4>
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.
</li>
<li><h4>Allow sharing files via the android share menu*:</h4>
Decrypt and share file with other apps. These apps could save and send the files thus shared.
</li>
<li><h4>Keep volume open when the app goes in background:</h4>
Don't close the volume when you leave the app but keep running it in the background. Anyone going back to the activity could have access to the volume.
</li>
<li><h4>Allow saving password hash using fingerprint:</h4>
Generate an AES-256 GCM key in the Android Keystore (protected by fingerprint authentication), then use it to encrypt the volume password hash and store it to the DroidFS internal storage. This require Android v6.0+. If your device is not encrypted, extracting the encryption key with physical access may be possible.
</li>
Note: apps with root access don't care about this flag: they can take screenshots or record the screen of any app without any permissions.</li>
<li><b>Allow exporting files:</b>
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.</li>
<li><b>Allow sharing files via the android share menu⁽¹⁾:</b>
Decrypt and share file with other apps. These apps could save and send the files thus shared.</li>
<li><b>Allow saving password hash using fingerprint:</b>
Generate an AES-256 GCM key in the Android Keystore (protected by fingerprint authentication), then use it to encrypt the volume password hash and store it to the DroidFS internal storage. This require Android v6.0+. If your device is not encrypted, extracting the encryption key with physical access may be possible.</li>
<li><b>Disable volume auto-locking:</b> (previously called <i>"Keep volumes open when the app goes in background"</i>)
Don't close open volumes when you leave the app. Anyone going back to the application could have access to open volumes. Cryptographic secrets are kept in memory for an undefined amount of time.</li>
<li><b>Keep volumes open:</b>
(Different from the old <i>"Keep volumes open when the app goes in background"</i>. Yes it's confusing, sorry)
Keep the app running as a [foreground service](https://developer.android.com/develop/background-work/services/foreground-services) to maintain volumes open, even when the app is removed from recent tasks.
This avoid the app from being killed by the system during file operations or while accessing exposed volumes, but this mean cryptographic secrets stay in memory for an undefined amount of time.</li>
<li><b>Allow opening files with other applications⁽¹⁾:</b>
Decrypt and open file using external apps. These apps could save and send the files thus opened.</li>
<li><b>Expose open volumes⁽¹⁾:</b>
Allow open volumes to be browsed in the system file explorer (<a href="https://developer.android.com/guide/topics/providers/document-provider">DocumentProvider</a> API). Encrypted files can then be selected from other applications, potentially with permanent access. This feature requires <i>"Disable volume auto-locking"</i>, and works more reliably when <i>"Keep volumes open"</i> is also enabled.</li>
<li><b>Grant write access:</b>
Files opened with another applications can be modified by them. This applies to both previous unsafe features.</li>
</ul>
* Features requiring temporary writing of the plain file to disk (DroidFS internal storage). This file could be read by apps with root access or by physical access if your device is not encrypted.
⁽¹⁾: These features can work in two ways: temporarily writing the plain file to disk (DroidFS internal storage) or sharing it via memory. By default, DroidFS will choose to keep the file only in memory as it's more secure, but will fallback to disk export if the file is too large to be held in memory. This behavior can be changed with the *"Export method"* parameter in the settings. Please note that some applications require the file to be stored on disk, and therefore do not work with memory-exported files.
# Download
<a href="https://f-droid.org/packages/sushi.hardcore.droidfs">
@ -83,23 +96,14 @@ 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), nor can it allow other applications to access encrypted volumes once opened.
DroidFS works as a wrapper around modified versions of the original encrypted container implementations ([libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) and [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs)). These programs were designed to run on standard x86 Linux systems: they access the underlying file system with file paths and syscalls. However, on Android, you can't access files from other applications using file paths. Instead, one has to use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. Obviously, neither Gocryptfs nor CryFS support this API. As a result, DroidFS cannot open volumes provided by other applications (such as cloud storage clients). If you want to synchronize your volumes on a cloud, the cloud application must synchronize the encrypted directory from disk.
Due to Android's storage restrictions, encrypted volumes located on SD cards must be placed under `/Android/data/sushi.hardcore.droidfs/` if you want DroidFS to be able to modify them.

View File

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

View File

@ -13,15 +13,9 @@ if (hasProperty("disableGocryptfs")) {
ext.disableGocryptfs = false
}
if (hasProperty("nosplits")) {
ext.splits = false
} else {
ext.splits = true
}
android {
compileSdk 34
ndkVersion "25.1.8937393"
ndkVersion '25.2.9519653'
namespace "sushi.hardcore.droidfs"
compileOptions {
@ -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 32
versionName "2.0.2"
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,19 +59,20 @@ android {
}
}
if (project.ext.splits) {
splits {
abi {
enable true
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 {
@ -103,34 +109,33 @@ android {
dependencies {
implementation project(":libpdfviewer:app")
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'androidx.core:core-ktx:1.12.0'
implementation "androidx.appcompat:appcompat:1.6.1"
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.6.2"
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.3.1"
implementation "androidx.preference:preference-ktx:1.2.1"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'com.google.android.material:material:1.9.0'
implementation 'com.github.bumptech.glide:glide:4.15.1'
implementation 'com.google.android.material:material:1.12.0'
implementation 'com.github.bumptech.glide:glide:4.16.0'
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
def exoplayer_version = "2.19.1"
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
def media3_version = "1.3.1"
implementation "androidx.media3:media3-exoplayer:$media3_version"
implementation "androidx.media3:media3-ui:$media3_version"
implementation "androidx.media3:media3-datasource:$media3_version"
implementation "androidx.concurrent:concurrent-futures:1.1.0"
def camerax_version = "1.3.0-rc01"
def camerax_version = "1.3.4"
implementation "androidx.camera:camera-camera2:$camerax_version"
implementation "androidx.camera:camera-lifecycle:$camerax_version"
implementation "androidx.camera:camera-view:$camerax_version"
implementation "androidx.camera:camera-extensions:$camerax_version"
def autoValueVersion = "1.10.1"
// dependencies needed by CameraX patch
implementation "androidx.concurrent:concurrent-futures:1.2.0"
def autoValueVersion = '1.10.4'
implementation "com.google.auto.value:auto-value-annotations:$autoValueVersion"
annotationProcessor "com.google.auto.value:auto-value:$autoValueVersion"
}

View File

@ -1,2 +1 @@
ffmpeg
build

View File

@ -1,13 +1,13 @@
#!/bin/bash
set -e
if [ -z ${ANDROID_NDK_HOME+x} ]; then
echo "Error: \$ANDROID_NDK_HOME is not defined." >&2
exit 1
elif [ $# -lt 1 ]; then
echo "Usage: $0 <FFmpeg source directory> [<ABI>]" >&2
exit 1
else
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 \
>/dev/null &&
make -j $(nproc --all) >/dev/null) &&
mkdir -p build/$1/libavformat build/$1/libavcodec build/$1/libavutil &&
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so build/$1/libavformat &&
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so build/$1/libavcodec &&
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so build/$1/libavutil ||
exit 1
--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 6388eaf433a4196f10389921d5e346c90ee3d793
Subproject commit cd0af7088066f870f12eceed9836bde897f1d164

@ -1 +1 @@
Subproject commit 4f32853ae5ac70811b451cac60ed36fd5b93cbc8
Subproject commit b221d4cf3c70edc711169a769a476802d2577b2b

View File

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

View File

@ -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" />
@ -53,11 +56,13 @@
<activity android:name=".file_viewers.AudioPlayer" android:configChanges="screenSize|orientation" />
<activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" />
<activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
<activity android:name=".LogcatActivity"/>
<service android:name=".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>

View File

@ -35,6 +35,7 @@ import android.media.MediaCodec.BufferInfo;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.os.Bundle;
import android.os.SystemClock;
import android.util.Range;
import android.view.Surface;
@ -1054,6 +1055,7 @@ public class SucklessEncoderImpl implements Encoder {
if (mIsVideoEncoder) {
Timebase inputTimebase;
if (DeviceQuirks.get(CameraUseInconsistentTimebaseQuirk.class) != null) {
Logger.w(mTag, "CameraUseInconsistentTimebaseQuirk is enabled");
inputTimebase = null;
} else {
inputTimebase = mInputTimebase;
@ -1065,7 +1067,7 @@ public class SucklessEncoderImpl implements Encoder {
}
@Override
public void onInputBufferAvailable(MediaCodec mediaCodec, int index) {
public void onInputBufferAvailable(@NonNull MediaCodec mediaCodec, int index) {
mEncoderExecutor.execute(() -> {
if (mStopped) {
Logger.w(mTag, "Receives input frame after codec is reset.");
@ -1131,6 +1133,15 @@ public class SucklessEncoderImpl implements Encoder {
if (checkBufferInfo(bufferInfo)) {
if (!mHasFirstData) {
mHasFirstData = true;
// Only print the first data to avoid flooding the log.
Logger.d(mTag,
"data timestampUs = " + bufferInfo.presentationTimeUs
+ ", data timebase = " + mInputTimebase
+ ", current system uptimeMs = "
+ SystemClock.uptimeMillis()
+ ", current system realtimeMs = "
+ SystemClock.elapsedRealtime()
);
}
BufferInfo outBufferInfo = resolveOutputBufferInfo(bufferInfo);
mLastSentAdjustedTimeUs = outBufferInfo.presentationTimeUs;

View File

@ -5,7 +5,7 @@ Create the `new` folder if needed:
mkdir -p new
```
Put new CameraX files from upstream in the `new` folder.
Put the new CameraX files from upstream (`androidx.camera.video.Recorder`, `androidx.camera.video.Recording`, `androidx.camera.video.PendingRecording` and `androidx.camera.video.internal.encoder.EncoderImpl`) in the `new` folder.
Perform the 3 way merge:
```

View File

@ -35,6 +35,7 @@ import android.media.MediaCodec.BufferInfo;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.os.Bundle;
import android.os.SystemClock;
import android.util.Range;
import android.view.Surface;
@ -1053,6 +1054,7 @@ public class EncoderImpl implements Encoder {
if (mIsVideoEncoder) {
Timebase inputTimebase;
if (DeviceQuirks.get(CameraUseInconsistentTimebaseQuirk.class) != null) {
Logger.w(mTag, "CameraUseInconsistentTimebaseQuirk is enabled");
inputTimebase = null;
} else {
inputTimebase = mInputTimebase;
@ -1064,7 +1066,7 @@ public class EncoderImpl implements Encoder {
}
@Override
public void onInputBufferAvailable(MediaCodec mediaCodec, int index) {
public void onInputBufferAvailable(@NonNull MediaCodec mediaCodec, int index) {
mEncoderExecutor.execute(() -> {
if (mStopped) {
Logger.w(mTag, "Receives input frame after codec is reset.");
@ -1130,6 +1132,15 @@ public class EncoderImpl implements Encoder {
if (checkBufferInfo(bufferInfo)) {
if (!mHasFirstData) {
mHasFirstData = true;
// Only print the first data to avoid flooding the log.
Logger.d(mTag,
"data timestampUs = " + bufferInfo.presentationTimeUs
+ ", data timebase = " + mInputTimebase
+ ", current system uptimeMs = "
+ SystemClock.uptimeMillis()
+ ", current system realtimeMs = "
+ SystemClock.elapsedRealtime()
);
}
BufferInfo outBufferInfo = resolveOutputBufferInfo(bufferInfo);
mLastSentAdjustedTimeUs = outBufferInfo.presentationTimeUs;

View File

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

View File

@ -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())

View File

@ -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
@ -18,6 +21,7 @@ import androidx.annotation.RequiresApi
import androidx.camera.core.AspectRatio
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.DynamicRange
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
@ -41,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
@ -51,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
@ -112,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) {
@ -414,7 +423,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
refreshVideoCapture()
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture)
if (qualities == null) {
qualities = QualitySelector.getSupportedQualities(camera!!.cameraInfo)
qualities = SucklessRecorder.getVideoCapabilities(camera!!.cameraInfo).getSupportedQualities(DynamicRange.UNSPECIFIED)
}
videoCapture
} else {
@ -576,11 +585,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
override fun onResume() {
super.onResume()
if (encryptedVolume.isClosed()) {
finish()
} else {
sensorOrientationListener.addListener(this)
}
sensorOrientationListener.addListener(this)
}
override fun onOrientationChange(newOrientation: Int) {

View File

@ -16,7 +16,7 @@ import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.WidgetUtil
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.util.*
@ -89,8 +89,8 @@ class ChangePasswordActivity: BaseActivity() {
}
private fun changeVolumePassword() {
val newPassword = WidgetUtil.encodeEditTextContent(binding.editNewPassword)
val newPasswordConfirm = WidgetUtil.encodeEditTextContent(binding.editPasswordConfirm)
val newPassword = UIUtils.encodeEditTextContent(binding.editNewPassword)
val newPasswordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
@SuppressLint("NewApi")
if (!newPassword.contentEquals(newPasswordConfirm)) {
Toast.makeText(this, R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
@ -135,7 +135,7 @@ class ChangePasswordActivity: BaseActivity() {
null
}
val currentPassword = if (givenHash == null) {
WidgetUtil.encodeEditTextContent(binding.editCurrentPassword)
UIUtils.encodeEditTextContent(binding.editCurrentPassword)
} else {
null
}

View File

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

View File

@ -7,7 +7,10 @@ import android.os.Handler
import android.os.ParcelFileDescriptor
import android.system.Os
import android.util.Log
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Compat
@ -22,6 +25,23 @@ class EncryptedFileProvider(context: Context) {
companion object {
private const val TAG = "EncryptedFileProvider"
fun getTmpFilesDir(context: Context) = File(context.cacheDir, "tmp")
var exportMethod = ExportMethod.AUTO
}
enum class ExportMethod {
AUTO,
DISK,
MEMORY;
companion object {
fun parse(value: String) = when (value) {
"auto" -> EncryptedFileProvider.ExportMethod.AUTO
"disk" -> EncryptedFileProvider.ExportMethod.DISK
"memory" -> EncryptedFileProvider.ExportMethod.MEMORY
else -> throw IllegalArgumentException("Invalid export method: $value")
}
}
}
private val memoryInfo = ActivityManager.MemoryInfo()
@ -33,6 +53,11 @@ class EncryptedFileProvider(context: Context) {
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(
memoryInfo
)
PreferenceManager.getDefaultSharedPreferences(context)
.getString("export_method", null)?.let {
exportMethod = ExportMethod.parse(it)
}
}
class ExportedDiskFile private constructor(
@ -63,7 +88,9 @@ class EncryptedFileProvider(context: Context) {
}
override fun free() {
Wiper.wipe(file)
GlobalScope.launch(Dispatchers.IO) {
Wiper.wipe(file)
}
}
}
@ -118,16 +145,18 @@ class EncryptedFileProvider(context: Context) {
path: String,
size: Long,
): ExportedFile? {
return if (size > memoryInfo.availMem * 0.8) {
ExportedDiskFile.create(
path,
tmpFilesDir,
handler,
)
} else if (isMemFileSupported) {
ExportedMemFile.create(path, size) as ExportedFile
} else {
null
val diskFile by lazy { ExportedDiskFile.create(path, tmpFilesDir, handler) }
val memFile by lazy { ExportedMemFile.create(path, size) }
return when (exportMethod) {
ExportMethod.MEMORY -> memFile
ExportMethod.DISK -> diskFile
ExportMethod.AUTO -> {
if (isMemFileSupported && size < memoryInfo.availMem * 0.8) {
memFile
} else {
diskFile
}
}
}
}
@ -135,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 {

View File

@ -4,9 +4,9 @@ import java.io.File
object FileTypes {
private val FILE_EXTENSIONS = mapOf(
Pair("image", listOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "heic")),
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac")),
Pair("image", listOf("png", "jpg", "jpeg", "gif", "avif", "webp", "bmp", "heic")),
Pair("video", listOf("mp4", "webm", "mkv", "mov", "m4v")),
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac", "opus")),
Pair("pdf", listOf("pdf")),
Pair("text", listOf(
"asc",

View File

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

View File

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

View File

@ -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
@ -28,6 +24,7 @@ import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
import java.io.File
@ -130,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
}
}
@ -188,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()
}
@ -289,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
@ -310,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))
@ -354,7 +344,11 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main_activity, menu)
menu.findItem(R.id.settings).isVisible = !explorerRouter.pickMode && !explorerRouter.dropMode
val settingsVisible = !explorerRouter.pickMode && !explorerRouter.dropMode
menu.findItem(R.id.settings).isVisible = settingsVisible
if (settingsVisible) {
UIUtils.getMenuIconNeutralTint(this, menu).applyTo(R.id.settings, R.drawable.icon_settings)
}
val isSelecting = volumeAdapter.selectedItems.isNotEmpty()
menu.findItem(R.id.select_all).isVisible = isSelecting
menu.findItem(R.id.lock).isVisible = isSelecting && volumeAdapter.selectedItems.any {
@ -429,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 ->
@ -453,6 +444,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
.show()
}
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
TaskResult.State.CANCELLED -> {}
}
}
}
@ -477,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()) {

View File

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

View File

@ -1,5 +1,6 @@
package sushi.hardcore.droidfs
import android.app.ActivityOptions
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
@ -7,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)
@ -90,9 +93,15 @@ class SettingsActivity : BaseActivity() {
private fun refreshTheme() {
with(requireActivity()) {
startActivity(Intent(this, SettingsActivity::class.java))
startActivity(
Intent(this, SettingsActivity::class.java),
ActivityOptions.makeCustomAnimation(
this,
android.R.anim.fade_in,
android.R.anim.fade_out
).toBundle()
)
finish()
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
}
}
@ -120,6 +129,10 @@ class SettingsActivity : BaseActivity() {
false
}
}
findPreference<Preference>("logcat")?.setOnPreferenceClickListener { _ ->
startActivity(Intent(requireContext(), LogcatActivity::class.java))
true
}
}
}
@ -158,45 +171,66 @@ 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 ->
if (checked as Boolean) {
if (!Compat.isMemFileSupported()) {
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
.setTitle(R.string.error)
.setMessage("Your current kernel does not support memfd_create(). This feature requires a minimum kernel version of ${Compat.MEMFD_CREATE_MINIMUM_KERNEL_VERSION}.")
.setPositiveButton(R.string.ok, null)
.show()
return@setOnPreferenceChangeListener false
}
}
VolumeProvider.usfExpose = checked
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)
.setTitle(R.string.error)
.setMessage(getString(R.string.memfd_create_unsupported, Compat.MEMFD_CREATE_MINIMUM_KERNEL_VERSION))
.setPositiveButton(R.string.ok, null)
.show()
return@setOnPreferenceChangeListener false
}
EncryptedFileProvider.exportMethod = EncryptedFileProvider.ExportMethod.parse(newValue)
true
}
}

View File

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

View File

@ -9,7 +9,6 @@ import android.util.Log
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
import java.util.UUID
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 6) {
companion object {
@ -40,6 +39,37 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()
}
override fun onOpen(db: SQLiteDatabase) {
//check if database has been corrupted by v2.1.1
val cursor = db.rawQuery("SELECT * FROM $TABLE_NAME WHERE $COLUMN_TYPE IS NULL;", null)
if (cursor.count > 0) {
Log.w(TAG, "Found ${cursor.count} corrupted volumes")
while (cursor.moveToNext()) {
// fix columns left shift
val uuid = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)+5)
val name = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)-1)
val isHidden = cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)-1) == 1.toShort()
val type = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE)-1)[0]
val hash = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)-1)
val iv = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_IV)-1)
if (db.delete(TABLE_NAME, "$COLUMN_IV=?", arrayOf(uuid)) < 1) {
Log.e(TAG, "Failed to remove volume $name")
}
if (db.insert(TABLE_NAME, null, ContentValues().apply {
put(COLUMN_UUID, uuid)
put(COLUMN_NAME, name)
put(COLUMN_HIDDEN, isHidden)
put(COLUMN_TYPE, byteArrayOf(type))
put(COLUMN_HASH, hash)
put(COLUMN_IV, iv)
}) < 0) {
Log.e(TAG, "Failed to insert volume $name")
}
}
}
cursor.close()
}
private fun getNewVolumePath(volumeName: String): File {
return File(
VolumeData.getFullPath(volumeName, true, context.filesDir.path)
@ -95,22 +125,30 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
}
}
if (oldVersion < 6) {
val volumeCount = db.rawQuery("SELECT COUNT(*) FROM $TABLE_NAME", null).let { cursor ->
cursor.moveToNext()
cursor.getInt(0).also {
cursor.close()
}
val cursor = db.rawQuery("SELECT $COLUMN_NAME FROM $TABLE_NAME;", null)
val volumeNames = arrayOfNulls<String>(cursor.count)
var i = 0
while (cursor.moveToNext()) {
volumeNames[i++] = cursor.getString(0)
}
cursor.close()
if (volumeNames.isEmpty()) {
db.execSQL("DROP TABLE $TABLE_NAME;")
createTable(db)
} else {
db.execSQL("ALTER TABLE $TABLE_NAME RENAME TO OLD;")
createTable(db)
val uuidsValues = volumeNames.indices.joinToString(", ") { "('${VolumeData.newUuid()}', ?)" }
// add uuids to old data
db.execSQL(
"INSERT INTO $TABLE_NAME " +
"WITH uuids($COLUMN_UUID, $COLUMN_NAME) AS (VALUES $uuidsValues) " +
"SELECT $COLUMN_UUID, OLD.$COLUMN_NAME, $COLUMN_HIDDEN, $COLUMN_TYPE, $COLUMN_HASH, $COLUMN_IV " +
"FROM OLD JOIN uuids ON OLD.name = uuids.name;",
volumeNames
)
db.execSQL("DROP TABLE OLD;")
}
db.execSQL("ALTER TABLE $TABLE_NAME RENAME TO OLD;")
createTable(db)
val uuids = (0 until volumeCount).joinToString(", ") { "('${VolumeData.newUuid()}')" }
val baseColumns = "$COLUMN_NAME, $COLUMN_HIDDEN, $COLUMN_TYPE, $COLUMN_HASH, $COLUMN_IV"
// add uuids to old data
db.execSQL("INSERT INTO $TABLE_NAME " +
"SELECT uuid, $baseColumns FROM " +
"(SELECT $baseColumns, ROW_NUMBER() OVER () i FROM OLD) NATURAL JOIN " +
"(SELECT column1 uuid, ROW_NUMBER() OVER () i FROM (VALUES $uuids));")
db.execSQL("DROP TABLE OLD;")
}
}
@ -161,7 +199,7 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
put(COLUMN_TYPE, byteArrayOf(volume.type))
put(COLUMN_HASH, volume.encryptedHash)
put(COLUMN_IV, volume.iv)
}) == 1.toLong())
}) >= 0.toLong())
}
return false
}

View File

@ -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)
}
}

View File

@ -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 var usfKeepOpen = false
private val keepAliveServiceStartIntent by lazy {
Intent(this, KeepAliveService::class.java).apply {
action = KeepAliveService.ACTION_START
}.putExtra(
"notification", KeepAliveService.NotificationDetails(
"KeepAlive",
getString(R.string.keep_alive_notification_title),
getString(R.string.keep_alive_notification_text),
KeepAliveService.NotificationDetails.NotificationAction(
R.drawable.icon_lock,
getString(R.string.close_all),
ACTION_CLOSE_ALL_VOLUMES,
)
)
)
}
private val usfBackgroundDelegate = AndroidUtils.LiveBooleanPreference("usf_background", false) { _ ->
updateServicesStates()
}
private val usfBackground by usfBackgroundDelegate
private val usfKeepOpenDelegate = AndroidUtils.LiveBooleanPreference("usf_keep_open", false) { _ ->
updateServicesStates()
}
private val usfKeepOpen by usfKeepOpenDelegate
var isExporting = false
var isStartingExternalApp = false
val volumeManager = VolumeManager(this)
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()
}
}

View File

@ -13,7 +13,7 @@ import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.WidgetUtil
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.util.*
@ -123,7 +123,7 @@ class VolumeOpener(
apply()
}
}
val password = WidgetUtil.encodeEditTextContent(dialogBinding!!.editPassword)
val password = UIUtils.encodeEditTextContent(dialogBinding!!.editPassword)
val savePasswordHash = dialogBinding!!.checkboxSavePassword.isChecked
dialogBinding = null
// openVolumeWithPassword is responsible for wiping the password
@ -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)

View File

@ -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()
}
}

View File

@ -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)) {

View File

@ -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)
}

View File

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

View File

@ -67,17 +67,17 @@ class AddVolumeActivity: BaseActivity() {
finish()
}
fun onVolumeSelected(volume: VolumeData, rememberVolume: Boolean) {
if (rememberVolume) {
setResult(RESULT_USER_BACK)
finish()
} else {
volumeOpener.openVolume(volume, false, object : VolumeOpener.VolumeOpenerCallbacks {
override fun onVolumeOpened(id: Int) {
startExplorer(id, volume.shortName)
}
})
}
fun onVolumeAdded() {
setResult(RESULT_USER_BACK)
finish()
}
fun openVolume(volume: VolumeData, isVolumeKnown: Boolean) {
volumeOpener.openVolume(volume, isVolumeKnown, object : VolumeOpener.VolumeOpenerCallbacks {
override fun onVolumeOpened(id: Int) {
startExplorer(id, volume.shortName)
}
})
}
fun createVolume(volumePath: String, isHidden: Boolean, rememberVolume: Boolean) {

View File

@ -7,25 +7,37 @@ 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
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.WidgetUtil
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import 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"
@ -33,6 +45,17 @@ class CreateVolumeFragment: Fragment() {
private const val KEY_REMEMBER_VOLUME = "remember"
private const val KEY_PIN_PASSWORDS = Constants.PIN_PASSWORDS_KEY
private const val KEY_USF_FINGERPRINT = "fingerprint"
private val GOCRYPTFS_INFO = FileSystemInfo(
R.string.gocryptfs,
R.string.gocryptfs_details,
R.array.gocryptfs_encryption_ciphers,
)
private val CRYFS_INFO = FileSystemInfo(
R.string.cryfs,
R.string.cryfs_details,
R.array.cryfs_encryption_ciphers,
)
fun newInstance(
theme: Theme,
@ -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()
}
with(encryptionCipherAdapter) {
clear()
addAll(resources.getStringArray(ciphersArray).asList())
radio.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) {
with(encryptionCipherAdapter) {
clear()
addAll(resources.getStringArray(fs.ciphersResource).asList())
}
binding.radioGroupFilesystems.children.forEach {
if (it != root) {
it.findViewById<RadioButton>(R.id.radio).isChecked = false
}
}
}
}
binding.radioGroupFilesystems.addView(root)
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
binding.spinnerCipher.adapter = encryptionCipherAdapter
if (pinPasswords) {
@ -145,9 +171,18 @@ 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 = WidgetUtil.encodeEditTextContent(binding.editPassword)
val passwordConfirm = WidgetUtil.encodeEditTextContent(binding.editPasswordConfirm)
val password = UIUtils.encodeEditTextContent(binding.editPassword)
val passwordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
if (!password.contentEquals(passwordConfirm)) {
Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
Arrays.fill(password, 0)
@ -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,

View File

@ -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
} 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
}
}
private fun refreshButtonText() {
binding.buttonAction.text = getString(
if (pickMode || volumeAction == Action.ADD) {
if (binding.switchRemember.isChecked || currentVolumeData != null) {
R.string.add_volume
} else {
R.string.open_volume
}
private fun updateUi(volumeName: CharSequence = binding.editVolumeName.text) {
var warning = -1
fun updateWarning() {
if (warning == -1) {
binding.textWarning.isVisible = false
} else {
R.string.create_volume
binding.textWarning.isVisible = true
binding.textWarning.text = getString(warning)
}
)
}
}
private fun refreshStatus(content: CharSequence) {
val hidden = binding.switchHiddenVolume.isChecked
binding.editVolumeName.isVisible = hidden || inputViewModel.showEditText
binding.buttonPickDirectory.isVisible = !hidden
binding.textOr.isVisible = !hidden && !inputViewModel.showEditText
binding.buttonEnterPath.isVisible = !hidden && !inputViewModel.showEditText
if (hidden) {
binding.textLabel.text = getString(R.string.volume_name_label)
binding.editVolumeName.hint = getString(R.string.volume_name_hint)
} else {
binding.textLabel.text = getString(R.string.volume_path_label)
binding.editVolumeName.hint = getString(R.string.volume_path_hint)
}
if (hidden && volumeName.contains(PathUtils.SEPARATOR)) {
warning = R.string.error_slash_in_name
}
// exit early if possible to avoid filesystem queries
if (volumeName.isEmpty() || warning != -1 || (!hidden && !inputViewModel.showEditText)) {
binding.buttonAction.isVisible = false
binding.switchRemember.isVisible = false
updateWarning()
return
}
val path = File(getCurrentVolumePath())
volumeAction = if (path.isDirectory) {
if (path.list()?.isEmpty() == true || 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
}
currentVolumeData = if (volumeAction == Action.CREATE) {
null
} else {
volumeDatabase.getVolume(content.toString(), binding.switchHiddenVolume.isChecked)
}
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
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 {
binding.textWarning.text = getString(R.string.volume_alread_saved)
View.VISIBLE
volumeDatabase.getVolume(volumeName.toString(), hidden)
}
if (currentVolumeData != null) {
warning = R.string.volume_alread_saved
}
} else {
warning = R.string.choose_existing_volume
}
updateWarning()
}
private fun onDirectoryPicked(uri: Uri) {
val path = PathUtils.getFullPathFromTreeUri(uri, requireContext())
if (path != null)
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,71 +294,83 @@ 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 -> {
val volumeFile = File(volumePath)
var goodDirectory = false
if (volumeFile.isFile) {
Toast.makeText(requireContext(), R.string.error_is_file, Toast.LENGTH_SHORT).show()
} else if (volumeFile.isDirectory) {
val dirContent = volumeFile.list()
if (dirContent != null) {
if (dirContent.isEmpty()) {
if (volumeFile.canWrite()) {
goodDirectory = true
} else {
errorDirectoryNotWritable(volumePath)
}
if (volumeAction!! == Action.CREATE) {
val volumeFile = File(volumePath)
var goodDirectory = false
if (volumeFile.isFile) {
Toast.makeText(requireContext(), R.string.error_is_file, Toast.LENGTH_SHORT).show()
} else if (volumeFile.isDirectory) {
val dirContent = volumeFile.list()
if (dirContent != null) {
if (dirContent.isEmpty()) {
if (volumeFile.canWrite()) {
goodDirectory = true
} else {
Toast.makeText(requireContext(), R.string.dir_not_empty, Toast.LENGTH_SHORT).show()
errorDirectoryNotWritable(volumePath)
}
} else {
Toast.makeText(requireContext(), R.string.listdir_null_error_msg, Toast.LENGTH_SHORT).show()
Toast.makeText(requireContext(), R.string.dir_not_empty, Toast.LENGTH_SHORT)
.show()
}
} else {
if (File(PathUtils.getParentPath(volumePath)).canWrite()) {
goodDirectory = true
} else {
errorDirectoryNotWritable(volumePath)
}
Toast.makeText(
requireContext(),
R.string.listdir_null_error_msg,
Toast.LENGTH_SHORT
).show()
}
if (goodDirectory) {
(activity as AddVolumeActivity).createVolume(volumePath, isHidden, binding.switchRemember.isChecked)
} else {
if (File(PathUtils.getParentPath(volumePath)).canWrite()) {
goodDirectory = true
} else {
errorDirectoryNotWritable(volumePath)
}
}
Action.ADD -> {
val volumeType = EncryptedVolume.getVolumeType(volumePath)
if (volumeType < 0) {
CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error)
.setMessage(R.string.error_not_a_volume)
.setPositiveButton(R.string.ok, null)
.show()
} else if (!File(volumePath).canWrite()) {
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.warning)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext())) {
dialog.setView(
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
path.text = PathUtils.getPackageDataFolder(requireContext())
footer.text = getString(R.string.sdcard_error_add_footer)
}.root
)
} else {
dialog.setMessage(R.string.add_cant_write_warning)
}
dialog.show()
if (goodDirectory) {
(activity as AddVolumeActivity).createVolume(
volumePath,
isHidden,
binding.switchRemember.isChecked
)
}
} else {
val volumeType = EncryptedVolume.getVolumeType(volumePath)
if (volumeType < 0) {
CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.error)
.setMessage(R.string.error_not_a_volume)
.setPositiveButton(R.string.ok, null)
.show()
} else if (!File(volumePath).canWrite()) {
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
.setTitle(R.string.warning)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ -> onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext())) {
dialog.setView(
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
path.text = PathUtils.getPackageDataFolder(requireContext())
footer.text = getString(R.string.sdcard_error_add_footer)
}.root
)
} else {
addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
dialog.setMessage(R.string.add_cant_write_warning)
}
dialog.show()
} else {
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()
}
}
}
}

View File

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

View File

@ -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
@ -236,14 +237,21 @@ class VolumeProvider: DocumentsProvider() {
): String? {
if (!usfExpose || !usfSafWrite) return null
val document = parseDocumentId(parentDocumentId) ?: return null
val newFile = PathUtils.pathJoin(document.path, displayName)
val f = document.encryptedVolume.openFileWriteMode(newFile)
return if (f == -1L) {
Log.e(TAG, "Failed to create file: $document")
null
val path = PathUtils.pathJoin(document.path, displayName)
var success = false
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
success = document.encryptedVolume.mkdir(path)
} else {
document.encryptedVolume.closeFile(f)
document.rootId+newFile
val f = document.encryptedVolume.openFileWriteMode(path)
if (f != -1L) {
document.encryptedVolume.closeFile(f)
success = true
}
}
return if (success) {
document.rootId+path
} else {
null
}
}

View File

@ -1,20 +1,17 @@
package sushi.hardcore.droidfs.explorers
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.Uri
import android.os.Bundle
import android.os.IBinder
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageButton
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.addCallback
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
@ -23,10 +20,12 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.EncryptedFileProvider
@ -49,6 +48,8 @@ import sushi.hardcore.droidfs.file_viewers.VideoPlayer
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.util.finishOnClose
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -69,6 +70,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
protected lateinit var fileOperationService: FileOperationService
protected val activityScope = MainScope()
private var directoryLoadingTask: Job? = null
protected lateinit var explorerElements: MutableList<ExplorerElement>
protected lateinit var explorerAdapter: ExplorerElementAdapter
protected lateinit var app: VolumeManagerApp
@ -79,6 +81,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
private lateinit var titleText: TextView
private lateinit var recycler_view_explorer: RecyclerView
private lateinit var refresher: SwipeRefreshLayout
private lateinit var loader: ProgressBar
private lateinit var textDirEmpty: TextView
private lateinit var currentPathText: TextView
private lateinit var numberOfFilesText: TextView
@ -92,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)
@ -101,6 +104,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
init()
recycler_view_explorer = findViewById(R.id.recycler_view_explorer)
refresher = findViewById(R.id.refresher)
loader = findViewById(R.id.loader)
textDirEmpty = findViewById(R.id.text_dir_empty)
currentPathText = findViewById(R.id.current_path_text)
numberOfFilesText = findViewById(R.id.number_of_files_text)
@ -181,22 +185,16 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
setContentView(R.layout.activity_explorer)
}
protected open fun bindFileOperationService(){
Intent(this, FileOperationService::class.java).also {
bindService(it, object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as FileOperationService.LocalBinder
fileOperationService = binder.getService()
}
override fun onServiceDisconnected(arg0: ComponentName) {}
}, Context.BIND_AUTO_CREATE)
protected open fun bindFileOperationService() {
FileOperationService.bind(this) {
fileOperationService = it
}
}
private fun startFileViewer(cls: Class<*>, filePath: String) {
val intent = Intent(this, cls).apply {
putExtra("path", filePath)
putExtra("volume", encryptedVolume)
putExtra("volumeId", volumeId)
putExtra("sortOrder", sortOrderValues[currentSortOrderIndex])
}
startActivity(intent)
@ -259,6 +257,27 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
.show()
}
protected fun createNewFile(callback: (Long) -> Unit) {
EditTextDialog(this, R.string.enter_file_name) {
if (it.isEmpty()) {
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
createNewFile(callback)
} else {
val filePath = PathUtils.pathJoin(currentDirectoryPath, it)
val handleID = encryptedVolume.openFileWriteMode(filePath)
if (handleID == -1L) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.file_creation_failed)
.setPositiveButton(R.string.ok, null)
.show()
} else {
callback(handleID)
}
}
}.show()
}
private fun setVolumeNameTitle() {
titleText.text = getString(R.string.volume, volumeName)
}
@ -312,17 +331,15 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
private fun displayExplorerElements() {
synchronized(this) {
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
}
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
unselectAll(false)
loader.isVisible = false
recycler_view_explorer.isVisible = true
explorerAdapter.explorerElements = explorerElements
val sharedPrefsEditor = sharedPrefs.edit()
sharedPrefsEditor.putString(Constants.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
sharedPrefsEditor.apply()
}
private fun recursiveSetSize(directory: ExplorerElement) {
private suspend fun recursiveSetSize(directory: ExplorerElement) {
yield()
for (child in encryptedVolume.readDir(directory.fullPath) ?: return) {
if (child.isDirectory) {
recursiveSetSize(child)
@ -346,15 +363,16 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) {
synchronized(this) {
explorerElements = encryptedVolume.readDir(path) ?: return
if (path != "/") {
explorerElements.add(
0,
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
)
}
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) = lifecycleScope.launch {
directoryLoadingTask?.cancelAndJoin()
recycler_view_explorer.isVisible = false
loader.isVisible = true
explorerElements = encryptedVolume.readDir(path) ?: return@launch
if (path != "/") {
explorerElements.add(
0,
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
)
}
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.GONE
currentDirectoryPath = path
@ -362,22 +380,19 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
displayNumberOfElements(numberOfFilesText, R.string.one_file, R.string.multiple_files, explorerElements.count { it.isRegularFile })
displayNumberOfElements(numberOfFoldersText, R.string.one_folder, R.string.multiple_folders, explorerElements.count { it.isDirectory })
if (mapFolders) {
lifecycleScope.launch {
var totalSize: Long = 0
withContext(Dispatchers.IO) {
synchronized(this@BaseExplorerActivity) {
for (element in explorerElements) {
if (element.isDirectory) {
recursiveSetSize(element)
}
totalSize += element.stat.size
}
var totalSize: Long = 0
directoryLoadingTask = launch(Dispatchers.IO) {
for (element in explorerElements) {
if (element.isDirectory) {
recursiveSetSize(element)
}
totalSize += element.stat.size
}
displayExplorerElements()
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
onDisplayed?.invoke()
}
directoryLoadingTask!!.join()
displayExplorerElements()
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
onDisplayed?.invoke()
} else {
displayExplorerElements()
totalSizeText.text = getString(
@ -560,14 +575,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
private fun setMenuIconTint(menu: Menu, iconColor: Int, menuItemId: Int, drawableId: Int) {
menu.findItem(menuItemId)?.let {
it.icon = ContextCompat.getDrawable(this, drawableId)?.apply {
setTint(iconColor)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menu.findItem(R.id.rename).isVisible = false
menu.findItem(R.id.open_as)?.isVisible = false
@ -575,9 +582,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
menu.findItem(R.id.external_open)?.isVisible = false
}
val noItemSelected = explorerAdapter.selectedItems.isEmpty()
val iconColor = ContextCompat.getColor(this, R.color.neutralIconTint)
setMenuIconTint(menu, iconColor, R.id.sort, R.drawable.icon_sort)
setMenuIconTint(menu, iconColor, R.id.share, R.drawable.icon_share)
with(UIUtils.getMenuIconNeutralTint(this, menu)) {
applyTo(R.id.sort, R.drawable.icon_sort)
applyTo(R.id.share, R.drawable.icon_share)
}
menu.findItem(R.id.sort).isVisible = noItemSelected
menu.findItem(R.id.lock).isVisible = noItemSelected
menu.findItem(R.id.close).isVisible = noItemSelected
@ -607,7 +615,13 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
.setTitle(R.string.sort_order)
.setSingleChoiceItems(sortOrderEntries, currentSortOrderIndex) { dialog, which ->
currentSortOrderIndex = which
displayExplorerElements()
// displayExplorerElements must not be called if directoryLoadingTask is active
if (directoryLoadingTask?.isActive != true) {
displayExplorerElements()
}
val sharedPrefsEditor = sharedPrefs.edit()
sharedPrefsEditor.putString(Constants.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
sharedPrefsEditor.apply()
dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
@ -660,10 +674,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
if (app.isStartingExternalApp) {
TemporaryFileProvider.instance.wipe()
}
if (encryptedVolume.isClosed()) {
finish()
} else {
setCurrentPath(currentDirectoryPath)
}
setCurrentPath(currentDirectoryPath)
}
}

View File

@ -68,7 +68,11 @@ class ExplorerActivity : BaseExplorerActivity() {
private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris != null) {
for (uri in uris) {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
try {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
} catch (e: SecurityException) {
e.printStackTrace()
}
}
importFilesFromUris(uris) {
onImportComplete(uris)
@ -177,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" -> {
@ -189,9 +192,11 @@ class ExplorerActivity : BaseExplorerActivity() {
pickImportDirectory.launch(null)
}
"createFile" -> {
EditTextDialog(this, R.string.enter_file_name) {
createNewFile(it)
}.show()
createNewFile {
encryptedVolume.closeFile(it)
setCurrentPath(currentDirectoryPath)
invalidateOptionsMenu()
}
}
"createFolder" -> {
openDialogCreateFolder()
@ -199,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)
}
}
@ -219,26 +224,6 @@ class ExplorerActivity : BaseExplorerActivity() {
cancelItemAction()
}
private fun createNewFile(fileName: String){
if (fileName.isEmpty()) {
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
} else {
val filePath = PathUtils.pathJoin(currentDirectoryPath, fileName)
val handleID = encryptedVolume.openFileWriteMode(filePath)
if (handleID == -1L) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.file_creation_failed)
.setPositiveButton(R.string.ok, null)
.show()
} else {
encryptedVolume.closeFile(handleID)
setCurrentPath(currentDirectoryPath)
invalidateOptionsMenu()
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.explorer, menu)
val result = super.onCreateOptionsMenu(menu)
@ -343,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)
}

View File

@ -9,6 +9,8 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.nio.CharBuffer
import java.nio.charset.StandardCharsets
class ExplorerActivityDrop : BaseExplorerActivity() {
@ -30,15 +32,15 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
return when (item.itemId) {
R.id.validate -> {
val extras = intent.extras
val errorMsg: String? = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
val success = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
when (intent.action) {
Intent.ACTION_SEND -> {
val uri = IntentUtils.getParcelableExtra<Uri>(intent, Intent.EXTRA_STREAM)
if (uri == null) {
getString(R.string.share_intent_parsing_failed)
false
} else {
importFilesFromUris(listOf(uri), ::onImported)
null
true
}
}
Intent.ACTION_SEND_MULTIPLE -> {
@ -50,20 +52,34 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
}
if (uris != null) {
importFilesFromUris(uris, ::onImported)
null
true
} else {
getString(R.string.share_intent_parsing_failed)
false
}
}
else -> getString(R.string.share_intent_parsing_failed)
else -> false
}
} else if ((intent.clipData?.itemCount ?: 0) > 0) {
val byteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(intent.clipData!!.getItemAt(0).text))
val byteArray = ByteArray(byteBuffer.remaining())
byteBuffer.get(byteArray)
val size = byteArray.size.toLong()
createNewFile {
var offset = 0L
while (offset < size) {
offset += encryptedVolume.write(it, offset, byteArray, offset, size-offset)
}
encryptedVolume.closeFile(it)
onImported()
}
true
} else {
getString(R.string.share_intent_parsing_failed)
false
}
errorMsg?.let {
if (!success) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(it)
.setMessage(R.string.share_intent_parsing_failed)
.setPositiveButton(R.string.ok, null)
.show()
}

View File

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

View File

@ -1,65 +1,184 @@
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
if (!::notificationManager.isInitialized){
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)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@ -72,87 +191,187 @@ class FileOperationService : Service() {
)
}
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
notificationBuilder
.setContentTitle(getString(message))
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.addAction(NotificationCompat.Action(
R.drawable.icon_close,
getString(R.string.cancel),
PendingIntent.getBroadcast(
this,
0,
Intent(this, NotificationBroadcastReceiver::class.java).apply {
val bundle = Bundle()
bundle.putBinder("binder", LocalBinder())
bundle.putInt("notificationId", lastNotificationId)
putExtra("bundle", bundle)
action = ACTION_CANCEL
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
))
if (total != null) {
.setContentTitle(getString(task.title))
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.addAction(NotificationCompat.Action(
R.drawable.icon_close,
getString(R.string.cancel),
PendingIntent.getBroadcast(
this,
newTaskId,
Intent(this, NotificationBroadcastReceiver::class.java).apply {
putExtra("bundle", Bundle().apply {
putBinder("binder", LocalBinder())
putInt("taskId", newTaskId)
})
action = ACTION_CANCEL
},
PendingIntent.FLAG_IMMUTABLE
)
))
if (task.total != null) {
notificationBuilder
.setContentText("0/$total")
.setProgress(total, 0, false)
.setContentText("0/${task.total}")
.setProgress(task.total, 0, false)
} else {
notificationBuilder
.setContentText(getString(R.string.discovering_files))
.setProgress(0, 0, true)
}
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)
}
}

View File

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

View File

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

View File

@ -1,15 +1,19 @@
package sushi.hardcore.droidfs.file_viewers
import android.net.Uri
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.upstream.DataSource
import com.google.android.exoplayer2.upstream.DataSpec
import com.google.android.exoplayer2.upstream.TransferListener
import androidx.media3.common.C
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.TransferListener
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import kotlin.math.min
class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, private val filePath: String): DataSource {
@OptIn(UnstableApi::class)
class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, private val filePath: String):
DataSource {
private var fileHandle = -1L
private var fileOffset: Long = 0
private var bytesRemaining: Long = -1

View File

@ -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
}
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
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"
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
}
x
(currentPlaylistIndex - 1).mod(playlist.size)
}
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()
}
}
}

View File

@ -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,22 +107,21 @@ 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
goBackToExplorer()
lifecycleScope.launch {
if (deleteCurrentFile()) {
if (playlist.size == 0) { // no more image left
goBackToExplorer()
} else {
loadImage(true)
}
} else {
loadImage(true)
CustomAlertDialogBuilder(this@ImageViewer, theme)
.keepFullScreen()
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, fileName))
.setPositiveButton(R.string.ok, null)
.show()
}
} else {
CustomAlertDialogBuilder(this, theme)
.keepFullScreen()
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, fileName))
.setPositiveButton(R.string.ok, null)
.show()
}
}
.setNegativeButton(R.string.cancel, null)
@ -198,14 +199,16 @@ class ImageViewer: FileViewerActivity() {
rotateImage()
}
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false){
playlistNext(deltaX < 0)
loadImage(true)
if (slideshowActive) {
if (!slideshowSwipe) { //reset slideshow delay if user swipes
handler.removeCallbacks(slideshowNext)
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false) {
lifecycleScope.launch {
playlistNext(deltaX < 0)
loadImage(true)
if (slideshowActive) {
if (!slideshowSwipe) { // reset slideshow delay if user swipes
handler.removeCallbacks(slideshowNext)
}
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
}
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
}
}

View File

@ -1,16 +1,24 @@
package sushi.hardcore.droidfs.file_viewers
import android.view.WindowManager
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.video.VideoSize
import androidx.annotation.OptIn
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File
@OptIn(UnstableApi::class)
abstract class MediaPlayer: FileViewerActivity() {
private lateinit var player: ExoPlayer
@ -33,12 +41,16 @@ abstract class MediaPlayer: FileViewerActivity() {
private fun initializePlayer(){
player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
bindPlayer(player)
createPlaylist()
for (e in mappedPlaylist) {
player.addMediaSource(createMediaSource(e.fullPath))
player.addMediaSource(createMediaSource(filePath))
lifecycleScope.launch {
createPlaylist()
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) {
@ -61,9 +73,11 @@ 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)
refreshFileName()
if (player.repeatMode != Player.REPEAT_MODE_ONE && currentPlaylistIndex != -1) {
lifecycleScope.launch {
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % player.mediaItemCount)
refreshFileName()
}
}
}
})

View File

@ -3,8 +3,8 @@ package sushi.hardcore.droidfs.file_viewers
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.view.View
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.ui.StyledPlayerView
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import sushi.hardcore.droidfs.databinding.ActivityVideoPlayerBinding
class VideoPlayer: MediaPlayer() {
@ -19,7 +19,7 @@ class VideoPlayer: MediaPlayer() {
setContentView(binding.root)
applyNavigationBarMargin(binding.root)
binding.videoPlayer.doubleTapOverlay = binding.doubleTapOverlay
binding.videoPlayer.setControllerVisibilityListener(StyledPlayerView.ControllerVisibilityListener { visibility ->
binding.videoPlayer.setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { visibility ->
binding.topBar.visibility = visibility
if (visibility == View.VISIBLE) {
showPartialSystemUi()

View File

@ -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)
}

View File

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

View File

@ -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)
}

View 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)
}
}
}
}
}

View 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
}
})
}

View File

@ -3,6 +3,7 @@ package sushi.hardcore.droidfs.util
import android.content.ActivityNotFoundException
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.DocumentsContract
@ -111,24 +112,27 @@ object PathUtils {
}
}
Log.d(PATH_RESOLVER_TAG, "getExternalFilesDirs failed")
try {
val process = ProcessBuilder("mount").redirectErrorStream(true).start().apply { waitFor() }
process.inputStream.readBytes().decodeToString().split("\n").forEach { line ->
if (line.startsWith("/dev/block/vold")) {
Log.d(PATH_RESOLVER_TAG, "mount: $line")
val fields = line.split(" ")
if (fields.size >= 3) {
val path = fields[2]
if (File(path).name == name) {
return path
// Don't risk to be killed by SELinux on newer Android versions
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
try {
val process = ProcessBuilder("mount").redirectErrorStream(true).start().apply { waitFor() }
process.inputStream.readBytes().decodeToString().split("\n").forEach { line ->
if (line.startsWith("/dev/block/vold")) {
Log.d(PATH_RESOLVER_TAG, "mount: $line")
val fields = line.split(" ")
if (fields.size >= 3) {
val path = fields[2]
if (File(path).name == name) {
return path
}
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
} catch (e: Exception) {
e.printStackTrace()
Log.d(PATH_RESOLVER_TAG, "mount processing failed")
}
Log.d(PATH_RESOLVER_TAG, "mount processing failed")
return null
}

View File

@ -0,0 +1,45 @@
package sushi.hardcore.droidfs.util
/**
* Minimal ring buffer implementation.
*/
class RingBuffer<T>(private val capacity: Int) {
private val buffer = arrayOfNulls<Any?>(capacity)
/**
* Position of the first cell.
*/
private var head = 0
/**
* Position of the next free (or to be overwritten) cell.
*/
private var tail = 0
var size = 0
private set
fun addLast(e: T) {
buffer[tail] = e
tail = (tail + 1) % capacity
if (size < capacity) {
size += 1
} else {
head = (head + 1) % capacity
}
}
@Suppress("UNCHECKED_CAST")
fun popFirst() = buffer[head].also {
head = (head + 1) % capacity
size -= 1
} as T
/**
* Empty the buffer and call [f] for each element.
*/
fun drain(f: (T) -> Unit) {
repeat(size) {
f(popFirst())
}
}
}

View File

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

View File

@ -24,4 +24,6 @@ class Version(inputVersion: String) : Comparable<Version> {
}
0
}
override fun toString() = version
}

View File

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

View File

@ -189,10 +189,10 @@ internal class CircleClipTapView(context: Context, attrs: AttributeSet): View(co
super.onDraw(canvas)
// Background
canvas?.clipPath(shapePath)
canvas?.drawPath(shapePath, backgroundPaint)
canvas.clipPath(shapePath)
canvas.drawPath(shapePath, backgroundPaint)
// Circle
canvas?.drawCircle(cX, cY, currentRadius, circlePaint)
canvas.drawCircle(cX, cY, currentRadius, circlePaint)
}
}

View File

@ -2,29 +2,19 @@ package sushi.hardcore.droidfs.widgets
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.media.session.PlaybackState
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import com.google.android.exoplayer2.ui.StyledPlayerView
import sushi.hardcore.droidfs.R
import androidx.media3.ui.PlayerView
class DoubleTapPlayerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : StyledPlayerView(context, attrs, defStyleAttr) {
) : PlayerView(context, attrs, defStyleAttr) {
companion object {
const val SEEK_SECONDS = 10
@ -75,22 +65,7 @@ class DoubleTapPlayerView @JvmOverloads constructor(
handler.postDelayed(stopDoubleTap, 700)
}
}
private val gestureDetector = GestureDetectorCompat(context, gestureListener)
private val density by lazy {
context.resources.displayMetrics.density
}
private val originalExoIconPaddingBottom by lazy {
resources.getDimension(R.dimen.exo_icon_padding_bottom)
}
private val originalExoIconSize by lazy {
resources.getDimension(R.dimen.exo_icon_size)
}
init {
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
handleOrientationChange(Configuration.ORIENTATION_LANDSCAPE)
}
}
private val gestureDetector = GestureDetector(context, gestureListener)
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
@ -135,35 +110,4 @@ class DoubleTapPlayerView @JvmOverloads constructor(
}
}
}
private fun updateButtonSize(orientation: Int) {
val size = (if (orientation == Configuration.ORIENTATION_LANDSCAPE) 45*density else originalExoIconSize).toInt()
listOf(R.id.exo_prev, R.id.exo_rew_with_amount, R.id.exo_play_pause, R.id.exo_ffwd_with_amount, R.id.exo_next).forEach {
findViewById<View>(it).updateLayoutParams {
width = size
height = size
}
}
// fix text vertical alignment inside icons
val paddingBottom = (if (orientation == Configuration.ORIENTATION_LANDSCAPE) 15*density else originalExoIconPaddingBottom).toInt()
listOf(R.id.exo_rew_with_amount, R.id.exo_ffwd_with_amount).forEach {
findViewById<Button>(it).updatePadding(bottom = paddingBottom)
}
}
private fun handleOrientationChange(orientation: Int) {
val centerControls = findViewById<LinearLayout>(R.id.exo_center_controls)
(centerControls.parent as ViewGroup).removeView(centerControls)
findViewById<FrameLayout>(if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
R.id.center_controls_bar
} else {
R.id.center_controls_external
}).addView(centerControls)
updateButtonSize(orientation)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
handleOrientationChange(newConfig.orientation)
}
}

View File

@ -0,0 +1,111 @@
package sushi.hardcore.droidfs.widgets
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
import androidx.appcompat.widget.AppCompatTextView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
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.R
import sushi.hardcore.droidfs.util.RingBuffer
/**
* A [TextView] dropping first lines when appended too fast.
*
* The dropping rate depends on the size of the ring buffer (set by the
* [R.styleable.RingBufferTextView_updateMaxLines] attribute) and the UI update
* time. If the buffer becomes full before the UI finished to update, the first
* (oldest) lines are dropped such as only the latest appended lines in the buffer
* are going to be displayed.
*
* If the ring buffer never fills up completely, the content of the [TextView] can
* grow indefinitely.
*/
class RingBufferTextView: AppCompatTextView {
private var updateMaxLines = -1
private var averageLineLength = -1
/**
* Lines ring buffer of capacity [updateMaxLines].
*
* Must never be used without acquiring the [bufferLock] mutex.
*/
private val buffer by lazy {
RingBuffer<String>(updateMaxLines)
}
private val bufferLock = Mutex()
/**
* Channel used to notify the worker coroutine that a new line has
* been appended to the ring buffer. No data is sent through it.
*
* We use a buffered channel with a capacity of 1 to ensure that the worker
* can be notified that at least one update occurred while allowing the
* sender to never block.
*
* A greater capacity is not desired because the worker empties the buffer each time.
*/
private val channel = Channel<Unit>(1)
private val scope = CoroutineScope(Dispatchers.Default)
constructor(context: Context, attrs: AttributeSet): super(context, attrs) { init(context, attrs) }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int): super(context, attrs, defStyleAttr) { init(context, attrs) }
private fun init(context: Context, attrs: AttributeSet) {
with (context.obtainStyledAttributes(attrs, R.styleable.RingBufferTextView)) {
updateMaxLines = getInt(R.styleable.RingBufferTextView_updateMaxLines, -1)
averageLineLength = getInt(R.styleable.RingBufferTextView_averageLineLength, -1)
recycle()
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
scope.launch {
val text = StringBuilder(updateMaxLines*averageLineLength)
while (isActive) {
channel.receive()
val size: Int
bufferLock.withLock {
size = buffer.size
buffer.drain {
text.appendLine(it)
}
}
withContext(Dispatchers.Main) {
if (size >= updateMaxLines) {
// Buffer full. Lines could have been dropped so we replace the content.
setText(text.toString())
} else {
super.append(text.toString())
}
}
text.clear()
}
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
scope.cancel()
}
/**
* Append a line to the ring buffer and update the UI.
*
* If the buffer is full (when adding [R.styleable.RingBufferTextView_updateMaxLines]
* lines before the UI had time to update), the oldest line is overwritten.
*/
suspend fun append(line: String) {
bufferLock.withLock {
buffer.addLast(line)
}
channel.trySend(Unit)
}
}

View File

@ -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);

View File

@ -40,7 +40,7 @@ struct Muxer {
jmethodID seek_method_id;
};
int write_packet(void* opaque, uint8_t* buff, int buff_size) {
int write_packet(void* opaque, const uint8_t* buff, int buff_size) {
struct Muxer* muxer = opaque;
JNIEnv *env;
(*muxer->jvm)->GetEnv(muxer->jvm, (void **) &env, JNI_VERSION_1_6);
@ -108,8 +108,8 @@ Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_addVideoTrack(JNIEnv *e
stream->codecpar->height = height;
stream->codecpar->format = AV_PIX_FMT_YUVJ420P;
stream->time_base = (AVRational) {1, frame_rate};
uint8_t* matrix = av_stream_new_side_data(stream, AV_PKT_DATA_DISPLAYMATRIX, sizeof(int32_t) * 9);
av_display_rotation_set((int32_t *) matrix, orientation_hint);
AVPacketSideData *side_data_packet = av_packet_side_data_new(&stream->codecpar->coded_side_data, &stream->codecpar->nb_coded_side_data, AV_PKT_DATA_DISPLAYMATRIX, sizeof(int32_t) * 9, 0);
av_display_rotation_set((int32_t *) side_data_packet->data, orientation_hint);
return stream->index;
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?attr/colorAccent" android:pathData="M5,16c0,3.87 3.13,7 7,7s7,-3.13 7,-7v-4L5,12v4zM16.12,4.37l2.1,-2.1 -0.82,-0.83 -2.3,2.31C14.16,3.28 13.12,3 12,3s-2.16,0.28 -3.09,0.75L6.6,1.44l-0.82,0.83 2.1,2.1C6.14,5.64 5,7.68 5,10v1h14v-1c0,-2.32 -1.14,-4.36 -2.88,-5.63zM9,9c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zM15,9c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1z"/>
</vector>

View File

@ -1,5 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
<vector android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
<path android:fillColor="?attr/colorAccent" android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z"/>
</vector>

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

View File

@ -15,11 +15,11 @@
android:textSize="@dimen/title_text_size"
android:padding="10dp"/>
<com.google.android.exoplayer2.ui.StyledPlayerControlView
<androidx.media3.ui.PlayerControlView
android:id="@+id/audio_controller"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:controller_layout_id="@layout/audio_exo_styled_player_control_view"
app:controller_layout_id="@layout/audio_exo_player_control_view"
app:show_timeout="0"
app:show_shuffle_button="true"
app:repeat_toggle_modes="all|one"/>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<sushi.hardcore.droidfs.widgets.RingBufferTextView
android:id="@+id/content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:updateMaxLines="500"
app:averageLineLength="100"
android:textIsSelectable="true"
android:typeface="monospace" />
</HorizontalScrollView>
</ScrollView>

View File

@ -26,20 +26,20 @@
android:layout_gravity="center_horizontal"
android:background="@android:color/transparent"
android:gravity="center"
android:layoutDirection="ltr"
android:padding="@dimen/exo_styled_controls_padding"
android:layout_marginBottom="-40dp"
android:clipToPadding="false">
android:clipToPadding="false"
android:layoutDirection="ltr"
android:layout_marginBottom="-40dp">
<ImageButton android:id="@id/exo_prev"
style="@style/ExoStyledControls.Button.Center.Previous"/>
<include layout="@layout/exo_styled_player_control_rewind_button" />
<include layout="@layout/exo_player_control_rewind_button" />
<ImageButton android:id="@id/exo_play_pause"
style="@style/ExoStyledControls.Button.Center.PlayPause"/>
<include layout="@layout/exo_styled_player_control_ffwd_button" />
<include layout="@layout/exo_player_control_ffwd_button" />
<ImageButton android:id="@id/exo_next"
style="@style/ExoStyledControls.Button.Center.Next"/>

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@id/exo_center_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@android:color/transparent"
android:gravity="center"
android:layoutDirection="ltr"
android:padding="@dimen/exo_styled_controls_padding"
android:clipToPadding="false">
<ImageButton android:id="@id/exo_prev"
style="@style/ExoStyledControls.Button.Center.Previous"/>
<include layout="@layout/exo_styled_player_control_rewind_button" />
<ImageButton android:id="@id/exo_play_pause"
style="@style/ExoStyledControls.Button.Center.PlayPause"/>
<include layout="@layout/exo_styled_player_control_ffwd_button" />
<ImageButton android:id="@id/exo_next"
style="@style/ExoStyledControls.Button.Center.Next"/>
</LinearLayout>
</merge>

View File

@ -1,148 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright 2020 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 0dp dimensions are used to prevent this view from influencing the size of
the parent view if it uses "wrap_content". It is expanded to occupy the
entirety of the parent in code, after the parent's size has been
determined. See: https://github.com/google/ExoPlayer/issues/8726.
-->
<View android:id="@id/exo_controls_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/exo_black_opacity_60"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="vertical">
<FrameLayout
android:id="@+id/center_controls_external"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal">
<include layout="@layout/exo_center_controls"/>
</FrameLayout>
<FrameLayout android:id="@id/exo_bottom_bar"
android:layout_width="match_parent"
android:layout_height="@dimen/exo_styled_bottom_bar_height"
android:background="@color/exo_bottom_bar_background"
android:layoutDirection="ltr">
<LinearLayout android:id="@id/exo_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/exo_styled_bottom_bar_time_padding"
android:paddingEnd="@dimen/exo_styled_bottom_bar_time_padding"
android:paddingLeft="@dimen/exo_styled_bottom_bar_time_padding"
android:paddingRight="@dimen/exo_styled_bottom_bar_time_padding"
android:layout_gravity="center_vertical|start"
android:layoutDirection="ltr">
<TextView android:id="@id/exo_position"
style="@style/ExoStyledControls.TimeText.Position"/>
<TextView
style="@style/ExoStyledControls.TimeText.Separator"/>
<TextView android:id="@id/exo_duration"
style="@style/ExoStyledControls.TimeText.Duration"/>
</LinearLayout>
<FrameLayout
android:id="@+id/center_controls_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
<LinearLayout android:id="@id/exo_basic_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layoutDirection="ltr">
<ImageButton android:id="@id/exo_vr"
style="@style/ExoStyledControls.Button.Bottom.VR"/>
<ImageButton android:id="@id/exo_shuffle"
style="@style/ExoStyledControls.Button.Bottom.Shuffle"/>
<ImageButton android:id="@id/exo_repeat_toggle"
style="@style/ExoStyledControls.Button.Bottom.RepeatToggle"/>
<ImageButton android:id="@id/exo_subtitle"
style="@style/ExoStyledControls.Button.Bottom.CC"/>
<ImageButton android:id="@id/exo_settings"
style="@style/ExoStyledControls.Button.Bottom.Settings"/>
<ImageButton android:id="@id/exo_fullscreen"
style="@style/ExoStyledControls.Button.Bottom.FullScreen"/>
<ImageButton android:id="@id/exo_overflow_show"
style="@style/ExoStyledControls.Button.Bottom.OverflowShow"/>
</LinearLayout>
<HorizontalScrollView android:id="@id/exo_extra_controls_scroll_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:visibility="invisible">
<LinearLayout android:id="@id/exo_extra_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layoutDirection="ltr">
<ImageButton android:id="@id/exo_overflow_hide"
style="@style/ExoStyledControls.Button.Bottom.OverflowHide"/>
</LinearLayout>
</HorizontalScrollView>
</FrameLayout>
</LinearLayout>
<View android:id="@id/exo_progress_placeholder"
android:layout_width="match_parent"
android:layout_height="@dimen/exo_styled_progress_layout_height"
android:layout_gravity="bottom"
android:layout_marginBottom="@dimen/exo_styled_progress_margin_bottom"/>
<LinearLayout android:id="@id/exo_minimal_controls"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginBottom="@dimen/exo_styled_minimal_controls_margin_bottom"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layoutDirection="ltr">
<ImageButton android:id="@id/exo_minimal_fullscreen"
style="@style/ExoStyledControls.Button.Bottom.FullScreen"/>
</LinearLayout>
</merge>

View File

@ -10,6 +10,12 @@
android:text="@string/dir_empty"
android:visibility="gone"/>
<ProgressBar
android:id="@+id/loader"
android:layout_centerInParent="true"
android:layout_width="40dp"
android:layout_height="40dp"/>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresher"
android:layout_width="match_parent"

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

View File

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

View File

@ -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
<TextView
android:id="@+id/text_label"
android:layout_width="wrap_content"
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="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">
<EditText
android:id="@+id/edit_volume_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="0.5"
android:inputType="text"
android:maxLines="1"
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:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
android:hint="@string/volume_path_hint"
android:importantForAutofill="no"
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>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/save"
app:showAsAction="ifRoom"
android:icon="@drawable/icon_save"
android:title="@string/save" />
</menu>

View File

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

View File

@ -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>
@ -252,4 +251,20 @@
<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">"إطار الوصول للقرص"</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>
</resources>

View File

@ -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,9 +74,8 @@
<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">Unsichere Funktionen verwalten</string>
<string name="manage_unsafe_features_summary">Aktivieren/Deaktivieren unsicherer Funktionen</string>
<string name="usf_home_warning_msg">DroidFS versucht, so sicher wie möglich zu sein. Sicherheit ist jedoch oft mit mangelndem Komfort verbunden. Aus diesem Grund bietet DroidFS zusätzliche unsichere Funktionen, die Sie je nach Bedarf aktivieren/deaktivieren können.\n\nWarnung: Diese Funktionen können UNSICHER sein. Verwenden Sie sie nicht, wenn Sie nicht genau wissen, was Sie tun. Es wird dringend empfohlen, die Dokumentation zu lesen, bevor Sie sie aktivieren.</string>
<string name="see_unsafe_features">Siehe unsichere Funktionen</string>

View File

@ -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>
@ -242,6 +241,7 @@
<string name="file_op_delete_msg">Eliminando archivos…</string>
<string name="volume_type">(%s)</string>
<string name="volume_type_read_only">(%s, Sólo lectura)</string>
<string name="volume_type_inaccessible">(%s, inaccesible)</string>
<string name="io_error">I/O Error.</string>
<string name="use_fingerprint">Utilizar huella dactilar en lugar de la contraseña actual</string>
<string name="remember_volume">Recordar volumen</string>
@ -272,4 +272,10 @@
<string name="export_failed_export">Error al exportar el archivo</string>
<string name="export_mem">Exportando a memoria…</string>
<string name="export_disk">Exportar a disco…</string>
<string name="memfd_create_unsupported">El kernel actual no soporta memfd_create(). Esta característica requiere una versión mínima del kernel de %s.</string>
<string name="export_method">Método de exportación</string>
<string name="export_method_summary">Método de exportación de archivos. Se utiliza para compartir, abrir externamente y acceder a archivos expuestos.</string>
<string name="debug">Depurar</string>
<string name="logcat_title">Logcat de DroidFS</string>
<string name="logcat_saved">Logcat guardado</string>
</resources>

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

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

View File

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

View File

@ -17,4 +17,10 @@
<item>Фиолетовый</item>
<item>Розовый</item>
</string-array>
<string-array name="export_methods">
<item>Автовыбор (зависит от доступной памяти)</item>
<item>Временный файл в хранилище (надёжно, но могут остаться следы)</item>
<item>Файл в памяти (безопаснее, но не всегда возможно)</item>
</string-array>
</resources>

View File

@ -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>
@ -262,4 +261,23 @@
<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">Журнал 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>

View File

@ -0,0 +1,52 @@
<resources>
<string-array name="sort_orders_entries">
<item>Ad</item>
<item>Boyut</item>
<item>Tarih</item>
<item>Ad (azalan)</item>
<item>Boyut (azalan)</item>
<item>Tarih (azalan)</item>
</string-array>
<string-array name="color_names">
<item>Yeşil</item>
<item>Kırmızı</item>
<item>Mavi</item>
<item>Sarı</item>
<item>Turuncu</item>
<item>Mor</item>
<item>Pembe</item>
</string-array>
<string-array name="export_methods">
<item>Otomatik (kullanılabilir belleğe bağlı olarak)</item>
<item>Diske geçici dışa aktarma (güvenilir ancak iz bırakabilir)</item>
<item>Bellek dosyası (daha güvenlidir ancak her zaman işe yaramaz)</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>

View File

@ -0,0 +1,281 @@
<resources>
<string name="app_name">DroidFS</string>
<string name="create_volume">Birim oluştur</string>
<string name="open"></string>
<string name="create">Oluştur</string>
<string name="change_password">Şifreyi değiştir</string>
<string name="password">Şifre</string>
<string name="import_files">Dosyaları içe aktar/Şifrele</string>
<string name="import_folder">Klasörü İçe Aktar/Şifrele</string>
<string name="discovering_files">Dosyalar keşfediliyor…</string>
<string name="mkdir">Klasör oluştur</string>
<string name="dir_empty">Klasör boş</string>
<string name="warning">Dikkat !</string>
<string name="ask_lock_volume">Bu birimi kilitlemek istediğinizden emin misiniz?</string>
<string name="ok">Tamam</string>
<string name="cancel">İptal</string>
<string name="enter_folder_name">Klasör adı:</string>
<string name="error">Hata</string>
<string name="error_filename_empty">Lütfen bir isim girin</string>
<string name="error_mkdir">Klasör oluşturulamadı.</string>
<string name="success_import">Başarılı bir şekilde içe aktarıldı !</string>
<string name="success_import_msg">Seçili dosyalar başarılı bir şekilde içe aktarıldı.</string>
<string name="import_failed">İçe aktarılamadı: %s</string>
<string name="export_failed">Dışa aktarılamadı: %s</string>
<string name="success_export">Başarılı bir şekilde dışa aktarıldı !</string>
<string name="remove_failed">Silinemedi: %s</string>
<string name="passwords_mismatch">Şifreler uyuşmuyor</string>
<string name="dir_not_empty">Seçili klasör boş değil</string>
<string name="create_volume_failed">Birim oluşturualamadı.</string>
<string name="open_volume_failed">ılamadı</string>
<string name="share_chooser">Dosyayı paylaş</string>
<string name="storage_perm_denied">Depolama izni reddedildi</string>
<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="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>
<string name="location">Konum: %s</string>
<string name="total_size">Toplam boyut: %s</string>
<string name="import_from_other_volume">Başka bir birimden içe aktar</string>
<string name="read_file_failed">Bu dosya açılamadı.</string>
<string name="volume">Birim: %s</string>
<string name="yes">Evet</string>
<string name="no">Hayır</string>
<string name="ask_for_wipe">Orijinal dosyaları silmek istiyor musunuz ?</string>
<string name="wipe_failed">Silinemedi: %s</string>
<string name="wipe_successful">Dosyalar başarılı bir şekilde silindi !</string>
<string name="rename">Yeniden adlandır</string>
<string name="rename_title">Yeni ad:</string>
<string name="rename_failed">Yeniden adlandırılamadı: %s</string>
<string name="sort_order">Sıralama biçimi:</string>
<string name="change_password_failed">İşlem başarısız oldu. Lütfen eski şifrenizi kontrol edin.</string>
<string name="share_menu_label">DroidFS ile şifrele</string>
<string name="share_intent_parsing_failed">Paylaşım isteği işlenemedi.</string>
<string name="listdir_null_error_msg">Bu dizine erişilemiyor</string>
<string name="fingerprint_save_checkbox_text">Parmak izini kullanarak şifre hash değerini kaydedin</string>
<string name="fingerprint_instruction">Lütfen parmak izi sensörüne dokunun</string>
<string name="illegal_block_size_exception">IllegalBlockSizeException</string>
<string name="illegal_block_size_exception_msg">Yeni bir parmak izi eklediyseniz bu durum meydana gelebilir. Hash depolamasının sıfırlanması bu sorunu çözebilir.</string>
<string name="reset_hash_storage">Hash depolamasını sıfırla</string>
<string name="MAC_verification_failed">İmza/MAC doğrulaması başarısız oldu. Android KeyStore veya kaydedilen hash değeri değiştirildi. Hash depolamasını sıfırlamak bu sorunu çözebilir.</string>
<string name="hash_storage_reset">Hash depolaması başarıyla sıfırlandı</string>
<string name="encrypt_action_description">Şifre hash değerini şifreleme ve kaydetme.</string>
<string name="decrypt_action_description">Şifre hash değeri şifreleniyor.</string>
<string name="title_activity_settings">DroidFS ayarları</string>
<string name="explorer">Tarayıcı</string>
<string name="settings_title_sort_order">Varsayılan sıralama düzeni</string>
<string name="usf_decrypt">Dosyaların dışa aktarılmasına/şifresinin çözülmesine izin ver</string>
<string name="usf_share">Android paylaşım menüsü aracılığıyla dosya paylaşımına izin ver</string>
<string name="usf_open">Dosyaların diğer uygulamalarla açılmasına izin ver</string>
<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="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>
<string name="usf_home_warning_msg">DroidFS mümkün olduğunca güvenli olmaya çalışır. Ancak güvenlik çoğu zaman konfor eksikliğini de beraberinde getirir. Bu nedenle DroidFS, ihtiyaçlarınıza göre etkinleştirebileceğiniz/devre dışı bırakabileceğiniz güvenli olmayan ek özellikler sunar.\n\nDikkat: bu özellikler GÜVENLİ OLMAYABİLİR. Ne yaptığınızı tam olarak bilmiyorsanız bunları kullanmayın. Bunları etkinleştirmeden önce belgeleri okumanız önemle tavsiye edilir.</string>
<string name="see_unsafe_features">Güvenli olmayan özellikleri görün</string>
<string name="open_as">Farklı</string>
<string name="image">Resim</string>
<string name="video">Video</string>
<string name="audio">Ses</string>
<string name="playing_failed">Bu dosya oynatılamadı: %s</string>
<string name="text">Metin</string>
<string name="save_failed">Kaydedilemedi</string>
<string name="file_saved">Dosya kaydedildi !</string>
<string name="ask_save">Dosya kaydedilmemiş değişiklikler içeriyor. Çıkmadan önce bunları kaydetmek istiyor musunuz ?</string>
<string name="save">Kaydet</string>
<string name="discard">Gözardı et</string>
<string name="word_wrap">Kelime kaydırma</string>
<string name="outofmemoryerror_msg">OutOfMemoryError: Bu dosya belleğe yüklenemeyecek kadar büyük.</string>
<string name="new_file">Yeni dosya oluştur</string>
<string name="enter_file_name">Dosya adı:</string>
<string name="file_creation_failed">Dosya oluşturulamadı.</string>
<string name="loading">Yükleniyor…</string>
<string name="loading_msg_create">Birim oluşturuluyor…</string>
<string name="loading_msg_change_password">Şifre değiştiriliyor…</string>
<string name="loading_msg_open">Birim açılıyor…</string>
<string name="loading_msg_export">Dosyalar dışa aktarılıyor…</string>
<string name="query_cursor_null_error_msg">Bu dosyaya erişilemiyor</string>
<string name="about">Hakkında</string>
<string name="github">GitHub</string>
<string name="github_summary">DroidFS Github deposu. Kaynak kodu, dokümantasyon, hata izleyici…</string>
<string name="gitea">Gitea</string>
<string name="gitea_summary">Chapril Gitea bulut sunucusundaki DroidFS deposu GitHub\'tan farklı olarak Gitea tamamen ücretsiz bir yazılımdır ve kendi kendine barındırılır. Kaynak kodu, belgeler, hata izleyici…</string>
<string name="share">Paylaş</string>
<string name="decrypt_files">Dışa aktar/Şifre çöz</string>
<string name="copy_failed">Kopyalanamadı: %s</string>
<string name="copy_success">Başarıyla kopyalandı !</string>
<string name="add">Ekle</string>
<string name="camera">Kamera</string>
<string name="picture_save_success">Resim şuraya kaydedildi: %s</string>
<string name="picture_save_failed">Bu resim kaydedilemedi.</string>
<string name="video_save_success">Resim şuraya kaydedildi: %s</string>
<string name="file_overwrite_question">%s zaten mevcut, üzerine yazmak istiyor musunuz ?</string>
<string name="dir_overwrite_question">%s zaten mevcut, içeriğini birleştirmek istiyor musunuz ?</string>
<string name="enter_new_name">Yeni ad girin</string>
<string name="copy_menu_title">Kopyala</string>
<string name="move_failed">Taşınamadı: %s</string>
<string name="move_success">Başarıyla taşındı !</string>
<string name="enter_timer_duration">Zamanlayıcı süresini girin (saniye olarak)</string>
<string name="path_error">Seçilen yol alınamadı.</string>
<string name="create_cant_write_error_msg">DroidFS\'nin bu yola yazma erişimi yok. Lütfen başka bir konum deneyin.</string>
<string name="add_cant_write_warning">DroidFS\'nin bu yola yazma erişimi yok. Salt okunur erişimle birim ekleniyor.</string>
<string name="sdcard_error_header">DroidFS yalnızca aşağıdaki durumlarda çıkarılabilir SD kartlara yazabilir:</string>
<string name="sdcard_error_add_footer">Salt okunur erişimle birim ekleme.</string>
<string name="sdcard_error_create_footer">Lütfen bu yolun bir alt dizinini veya dahili depolamayı kullanın.</string>
<string name="slideshow_stopped">Slayt gösterisi durduruldu</string>
<string name="slideshow_started">Slayt gösterisi başlatıldı</string>
<string name="ask_save_img_rotated">Resim döndürüldü. Bu değişiklikleri kaydedip orijinal resmin üzerine yazmak istiyor musunuz ?</string>
<string name="image_saved_successfully">Resim değişiklikleri başarıyla kaydedildi.</string>
<string name="bitmap_compress_failed">Bitmap sıkıştırılamadı.</string>
<string name="file_write_failed">Dosya yazılamadı.</string>
<string name="error_not_a_volume">Şifrelenmiş birim tanınmadı. Lütfen seçilen yolu kontrol edin.</string>
<string name="version">Versiyon</string>
<string name="error_cipher_null">Hata şifre boş</string>
<string name="key_permanently_invalidated_exception">KeyPermanentlyInvalidatedException</string>
<string name="key_permanently_invalidated_exception_msg">Yeni bir parmak izi eklemişsiniz gibi görünüyor. Kaydedilen şifrelerin hash değeri kullanılamaz hale geldi.</string>
<string name="usf_read_doc">Bu seçeneklerden herhangi birini etkinleştirmeden önce dikkatlice okumalısınız.</string>
<string name="usf_doc">Güvenli olmayan özellikler dokümantasyonu</string>
<string name="error_retrieving_filename">Şu URI için dosya adı alınamıyor: %s</string>
<string name="hidden_volume">Gizli birim</string>
<string name="error_slash_in_name">Birim adı eğik çizgi sembolü içeremez</string>
<string name="hidden_volume_warning">Gizli birimler uygulamanın dahili deposunda saklanır. Diğer uygulamalar bu birimleri root erişimi olmadan göremez. Ancak DroidFS\'yi kaldırırsanız veya uygulamanın verilerini temizlerseniz tüm gizli birimleriniz KAYBOLACAKTIR. Yedekleme yaptığınızdan emin olun !</string>
<string name="camera_perm_needed">Fotoğraf çekebilmek için kamera izni gerekiyor.</string>
<string name="choose_resolution">Bir çözünürlük seçin</string>
<string name="file_operations">Dosya işlemleri</string>
<string name="file_op_copy_msg">Dosyalar kopyalanıyor…</string>
<string name="file_op_import_msg">Dosyalar içe aktarılıyor...</string>
<string name="file_op_export_msg">Dosyalar dışa aktarılıyor...</string>
<string name="file_op_move_msg">Dosyalar taşınıyor…</string>
<string name="file_op_wiping_msg">Dosyalar siliniyor…</string>
<string name="folders_first">Önce klasörler</string>
<string name="folders_first_summary">Klasörleri listenin başında göster</string>
<string name="auto_fit_title">Video oynatıcı ekranı otomatik döndürme</string>
<string name="auto_fit_summary">Video boyutlarına uyacak şekilde ekranı otomatik olarak döndürün</string>
<string name="open_tree_failed">Dosya tarayıcısı bulunamadı. Lütfen bir tane yükleyin ve tekrar deneyin.</string>
<string name="close_volume">Birimi kapat</string>
<string name="sort_by">Sırala</string>
<string name="cut">Kes</string>
<string name="map_folders">Klasörleri eşleme</string>
<string name="map_folders_summary">Boyutlarını hesaplamak için klasörleri yinelemeli olarak eşleyin (büyük birimleri açarken bunu devre dışı bırakmalısınız)</string>
<string name="camera_optimization">Kamera optimizasyonu</string>
<string name="maximize_quality">Kaliteyi maksimuma çıkarın</string>
<string name="minimize_latency">Gecikmeyi minimuma indirin</string>
<string name="auto">Otomatik</string>
<string name="encryption_cipher_label">Şifreleme şifresi:</string>
<string name="theme">Tema</string>
<string name="thumbnails">Küçük resimler</string>
<string name="thumbnails_summary">Resimlerin ve videoların küçük resimlerini göster</string>
<string name="seek_seconds_forward">+%d saniye</string>
<string name="seek_seconds_backward">-%d saniye</string>
<string name="add_volume">Birim ekle</string>
<string name="pick_directory">Klasör seç</string>
<string name="volume_alread_saved">Birim zaten kayıtlı</string>
<string name="open_dialog_title">ılıyor %s:</string>
<string name="remove">Kaldır</string>
<string name="settings">Ayarlar</string>
<string name="select_all">Tümünü seç</string>
<string name="remove_fingerprint">Parmak izini kaldır</string>
<string name="unrecoverable_key_exception_msg">%s. Şifreleme anahtarı yüklenemedi.</string>
<string name="unrecoverable_key_exception">UnrecoverableKeyException</string>
<string name="delete_hidden_volume_question">%s gizli, sadece birimin yolunu unutmak mı yoksa tüm İÇERİĞİNİ SİLMEK mi istiyorsunuz?</string>
<string name="forget_only">Sadece unut</string>
<string name="delete_volume">Birimi sil</string>
<string name="hidden_volume_description">Birimi DroidFS dahili deposunda saklayın</string>
<string name="error_is_file">Hata: dosya zaten mevcut</string>
<string name="volume_path_label">Birimin yolunu seçin:</string>
<string name="volume_name_label">Birimin adını girin:</string>
<string name="volume_path_hint">Birim yolu</string>
<string name="volume_name_hint">Birim adı</string>
<string name="password_label">Birim şifresini girin:</string>
<string name="password_confirmation_label">Şifreyi tekrarlayın:</string>
<string name="password_confirmation_hint">Şifre (doğrulama)</string>
<string name="password_hash_saved">şifre hash değeri kaydedildi</string>
<string name="no_volumes_text">Kaydedilmiş birim yok, + düğmesini tıklayarak biraz ekleyin</string>
<string name="fingerprint_error_msg">Parmak izi kimlik doğrulaması kullanılamaz: %s.</string>
<string name="keyguard_not_secure">tuş kilidi güvenli değil</string>
<string name="no_hardware">uygun donanım bulunamadı</string>
<string name="hardware_unavailable">donanım mevcut değil</string>
<string name="no_fingerprint">kayıtlı parmak izi yok</string>
<string name="unknown_error">bilinmeyen hata</string>
<string name="biometric_error">Biyometri hatası: %s</string>
<string name="apply_to_all">Bu seçimi tüm gizli birimlere uygula</string>
<string name="select_volume">Birimi seç</string>
<string name="current_password_label">Mevcut birim parolasını girin:</string>
<string name="current_password_hint">Mevcut şifre</string>
<string name="new_password_label">Yeni birim şifresini girin:</string>
<string name="new_password_hint">Yeni şifre</string>
<string name="new_password_confirmation_label">Yeni şifreyi tekrarlayın:</string>
<string name="error_marshmallow_required">Bu özellik yalnızca Android 6.0 (Marshmallow) veya üzeri sürümlerde mevcuttur.</string>
<string name="copy_hidden_volume">Paylaşılan depolamaya kopyala</string>
<string name="copy_external_volume">Gizli bir kopya oluştur</string>
<string name="copy_volume_notification">Birim kopyalanıyor…</string>
<string name="hidden_volume_already_exists">Aynı ada sahip bir gizli birim zaten mevcut.</string>
<string name="pdf_document">PDF dökümanı</string>
<string name="thumbnail_max_size">Küçük resimler için maksimum boyut</string>
<string name="thumbnail_max_size_summary">Küçük resmin yüklenebileceği maksimum dosya boyutu. Mevcut değer: %s</string>
<string name="size_hint">Boyut (KB olarak)</string>
<string name="invalid_number">Geçersiz numara</string>
<string name="new_volume_name">Yeni birim adı:</string>
<string name="volume_rename_failed">Birim yeniden adlandırılamadı</string>
<string name="switch_display_layout">Ekran düzenini değiştir</string>
<string name="one_file">1 dosya</string>
<string name="multiple_files">%d dosya</string>
<string name="one_folder">1 klasör</string>
<string name="multiple_folders">%d klasör</string>
<string name="default_open">Uygulamayı başlattığınızda bu birimi açın</string>
<string name="remove_default_open">Varsayılan olarak açma</string>
<string name="elements_selected">%d/%d seçildi</string>
<string name="pin_passwords_title">Sayısal tuş takımı düzeni</string>
<string name="pin_passwords_summary">Birim parolalarını girerken sayısal tuş takımı düzeni kullanın</string>
<string name="volume_type_label">Birim türü:</string>
<string name="gocryptfs">Gocryptfs</string>
<string name="cryfs">CryFS</string>
<string name="gocryptfs_disabled">Gocryptfs desteği devre dışı bırakıldı</string>
<string name="cryfs_disabled">CryFS desteği devre dışı bırakıldı</string>
<string name="file_op_delete_msg">Dosyalar silindi…</string>
<string name="volume_type">(%s)</string>
<string name="volume_type_read_only">(%s, salt-okunur)</string>
<string name="volume_type_inaccessible">(%s, erişilemez)</string>
<string name="io_error">I/O hatası.</string>
<string name="use_fingerprint">Mevcut şifre yerine parmak izini kullan</string>
<string name="remember_volume">Birimi hatırla</string>
<string name="open_volume">Birimi aç</string>
<string name="choose_existing_volume">Lütfen mevcut bir birimi seçin</string>
<string name="volume_unlocked">Birimin kilidi açıldı</string>
<string name="lock_volume">Birimi kilitle</string>
<string name="lock">Kilitle</string>
<string name="ux">UX</string>
<string name="theme_color">Tema rengi</string>
<string name="theme_color_summary">Uygulama teması rengini değiştirme</string>
<string name="black_theme">Siyah tema</string>
<string name="password_fallback">Şifreye geri dönme</string>
<string name="password_fallback_summary">Parmak iziyle kimlik doğrulama iptal edildiğinde şifre sor</string>
<string name="unknown_error_code">Bilinmeyen hata kodu: %d</string>
<string name="config_load_error">Yapılandırma dosyası yüklenemiyor. Birimin erişilebilir olduğundan emin olun.</string>
<string name="wrong_password">Yapılandırma dosyasının şifresi çözülemiyor. Lütfen şifrenizi kontrol edin.</string>
<string name="filesystem_id_changed">Yapılandırma dosyasındaki dosya sistemi kimliği, bu birimi son açtığımız zamandan farklı. Bu, saldırganın dosya sistemini farklı bir sistemle değiştirdiği anlamına gelebilir.</string>
<string name="inaccessible_base_dir">Birim mevcut değil veya erişilemiyor.</string>
<string name="task_failed">Görev başarısız oldu: %s</string>
<string name="usf_expose">ık birimleri ortaya çıkarın</string>
<string name="usf_expose_summary">Diğer uygulamaların belge sağlayıcıları olarak açık birimlere göz atmasına izin ver</string>
<string name="usf_saf_write">Yazma erişimi ver</string>
<string name="usf_saf_write_summary">Dosyaları diğer uygulamalarla açarken yazma erişimi verin</string>
<string name="saf">Depolama Erişim Sistemi</string>
<string name="tmp_export_failed">Dışa aktarma başarısız oldu: %s</string>
<string name="export_failed_create">dışa aktarılan dosya oluşturulamıyor</string>
<string name="export_failed_export">dosya dışa aktarılamadı</string>
<string name="export_mem">Belleğe aktarılıyor…</string>
<string name="export_disk">Diske dışarı aktarılıyor…</string>
<string name="memfd_create_unsupported">Mevcut çekirdeğiniz memfd_create() özelliğini desteklemiyor. Bu özellik minimum %s çekirdek sürümünü gerektirir.</string>
<string name="export_method">Dışa aktarma yöntemi</string>
<string name="export_method_summary">Dosya dışa aktarma yöntemi. Açıkta kalan dosyaları paylaşmak, harici olarak açmak ve bunlara erişmek için kullanılır.</string>
<string name="debug">Debug</string>
<string name="logcat_title">DroidFS Logcat</string>
<string name="logcat_saved">Logcat kaydedildi</string>
</resources>

View File

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

View File

@ -0,0 +1,276 @@
<?xml version="1.0" encoding="utf-8"?>
<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">无法重命名: %s</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">块大小非法(illegalBlockSizeException)</string>
<string name="illegal_block_size_exception_msg">在添加指纹后会出现此问题,重置保存的哈希值可解决此问题,随后你需要重新关联哈希与指纹</string>
<string name="reset_hash_storage">重置保存的哈希</string>
<string name="MAC_verification_failed">签名/MAC验证失败。安卓密钥存储或者哈希存储已经受到修改。重置保存的哈希值可解决此问题随后你需要重新关联哈希与指纹</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="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">内存耗尽: 文件过大而无法读入内存</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上的仓库。存有源码文档以及BUG追踪等</string>
<string name="gitea">Gitea</string>
<string name="gitea_summary">DroidFS在Chapril Gitea站上的仓库。与Github不同, Gitea是完全的自主搭建的自由软件.存有源码文档以及BUG追踪等</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仅对可移动存储的如下路径有写入权限:</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">通过URI检索文件失败: %s</string>
<string name="hidden_volume">隐藏卷</string>
<string name="error_slash_in_name">卷名不应当包含斜杠</string>
<string name="hidden_volume_warning">隐藏卷存放在DroidFS的私有文件夹内其他应用在没有Root权限的情况下无法读取隐藏卷。如果你卸载DroidFS或者在应用管理器中清除了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">通过递归映射来计算文件夹的大小(如果文件夹很大启用该功能将导致APP卡顿)</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 是隐藏的你希望DroidFS暂时不在UI上显示该卷的入口还是希望DroidFS删除卷内的文件?</string>
<string name="forget_only">仅删除UI上的入口</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">Keyguard不安全</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.0及以上可用</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">允许的缩略图文件最大体积. 当前值:</string>
<string name="size_hint">大小(KB):</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">单文件</string>
<string name="multiple_files">%d个文件</string>
<string name="one_folder">文件夹</string>
<string name="multiple_folders">%d个文件夹</string>
<string name="default_open">启动DroidFS时自动打开卷</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">Gocrytfs支持已关闭</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">I/O出错</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">更改DroidFS用户界面主题色</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">文件系统的ID与上一次打开时的ID不一样. 这可能意味着攻击者已经将文件系统替换</string>
<string name="inaccessible_base_dir">卷不存在或者不可访问</string>
<string name="task_failed">任务失败: %s</string>
<string name="usf_expose">暴露已打开的卷</string>
<string name="usf_expose_summary">允许其他应用软件通过DocumentsProviders接口读取卷内的文件</string>
<string name="usf_saf_write">允许写入</string>
<string name="usf_saf_write_summary">允许其他应用读取和删改卷内的文件</string>
<string name="saf">存储访问框架(SAF)</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>
</resources>

View File

@ -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">
@ -38,6 +38,12 @@
<item>Pink</item>
</string-array>
<string-array name="export_methods">
<item>Auto (depending on available memory)</item>
<item>Temporary export on disk (reliable but may leave traces)</item>
<item>Memory file (safer but doesn\'t always work)</item>
</string-array>
<!-- don't translate the following otherwise the app will crash -->
<string-array name="sort_orders_values">
<item>name</item>
@ -57,4 +63,10 @@
<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>

View File

@ -2,4 +2,8 @@
<resources>
<attr name="buttonBackgroundColor" format="color"/>
<attr name="infoBarBackgroundColor" format="color"/>
<declare-styleable name="RingBufferTextView">
<attr name="updateMaxLines" format="integer"/>
<attr name="averageLineLength" format="integer"/>
</declare-styleable>
</resources>

View File

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

View File

@ -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>
@ -273,4 +272,23 @@
<string name="export_failed_export">failed to export file</string>
<string name="export_mem">Exporting to memory…</string>
<string name="export_disk">Exporting to disk…</string>
<string name="memfd_create_unsupported">Your current kernel does not support memfd_create(). This feature requires a minimum kernel version of %s.</string>
<string name="export_method">Export method</string>
<string name="export_method_summary">File export method. Used for sharing, external opening and accessing exposed files.</string>
<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>

View File

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

View File

@ -93,6 +93,16 @@
</PreferenceCategory>
<PreferenceCategory android:title="@string/debug">
<Preference
android:key="logcat"
android:title="Logcat"
android:summary="View the DroidFS logcat"
android:icon="@drawable/icon_debug"/>
</PreferenceCategory>
<PreferenceCategory android:title="@string/about">
<Preference
@ -110,7 +120,6 @@
</Preference>
<Preference
android:key="version"
android:icon="@drawable/icon_info"
android:title="@string/version"
android:summary="@string/versionName"/> <!--added by gradle at build time-->

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