Compare commits
1 Commits
master
...
volume-pro
Author | SHA1 | Date | |
---|---|---|---|
e851f381a0 |
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -7,6 +7,3 @@
|
||||
[submodule "app/libcryfs"]
|
||||
path = app/libcryfs
|
||||
url = https://forge.chapril.org/hardcoresushi/libcryfs.git
|
||||
[submodule "app/ffmpeg/ffmpeg"]
|
||||
path = app/ffmpeg/ffmpeg
|
||||
url = https://git.ffmpeg.org/ffmpeg.git
|
||||
|
51
BUILD.md
51
BUILD.md
@ -1,21 +1,18 @@
|
||||
# Introduction
|
||||
DroidFS relies on modified versions of the original encrypted filesystems programs to open volumes. [CryFS](https://github.com/cryfs/cryfs) is written in C++ while [gocryptfs](https://github.com/rfjakob/gocryptfs) is written in [Go](https://golang.org). Thus, building DroidFS requires the compilation of native code. However, for the sake of simplicity, the application has been designed in a modular way: you can build a version of DroidFS that supports both Gocryptfs and CryFS, or only one of the two.
|
||||
|
||||
Moreover, DroidFS aims to be accessible to as many people as possible. If you encounter any problems or need help with the build, feel free to open an issue, a discussion, or contact me (currently the main developer) by [email](mailto:gh@arkensys.dedyn.io) or on [Matrix](https://matrix.org): @hardcoresushi:matrix.underworld.fr
|
||||
Moreover, DroidFS aims to be accessible to as many people as possible. If you encounter any problems or need help with the build, feel free to open an issue, a discussion, or contact me by [email](mailto:hardcore.sushi@disroot.org) or on [Matrix](https://matrix.org): @hardcoresushi:matrix.underworld.fr
|
||||
|
||||
# Setup
|
||||
|
||||
The following two steps assume you're using a Debian-based Linux distribution. Package names might be similar for other distributions. Don't hesitate to ask if you're having trouble with this.
|
||||
|
||||
Install required packages:
|
||||
```
|
||||
$ sudo apt-get install openjdk-17-jdk-headless build-essential pkg-config git gnupg2 wget apksigner npm
|
||||
$ sudo apt-get install openjdk-11-jdk-headless build-essential pkg-config git gnupg2 wget apksigner
|
||||
```
|
||||
You also need to manually install the [Android SDK](https://developer.android.com/studio/index.html#command-tools) and the [Android Native Development Kit (NDK)](https://github.com/android/ndk/wiki/Unsupported-Downloads#r25c) version `25.2.9519653` (r25c). libcryfs cannot be built with newer NDK versions at the moment due to compatibility issues with [boost](https://www.boost.org). If you succeed in building it with a more recent version of NDK, please report it.
|
||||
You also need to manually install the [Android SDK](https://developer.android.com/studio/index.html#command-tools) and the [Android Native Development Kit (NDK)](https://developer.android.com/ndk/downloads) (r23 versions are recommended).
|
||||
|
||||
If you want a support for Gocryptfs volumes, you need to install [Go](https://golang.org/doc/install):
|
||||
If you want a support for Gocryptfs volumes, you must install [Go](https://golang.org/doc/install) and libssl:
|
||||
```
|
||||
$ sudo apt-get install golang-go
|
||||
$ sudo apt-get install golang-go libssl-dev
|
||||
```
|
||||
The code should be authenticated before being built. To verify the signatures, you will need my PGP key:
|
||||
```
|
||||
@ -38,17 +35,31 @@ __Don't continue if the verification fails!__
|
||||
|
||||
Initialize submodules:
|
||||
```
|
||||
$ git submodule update --init
|
||||
$ git submodule update --depth=1 --init
|
||||
```
|
||||
If you want Gocryptfs support, initliaze libgocryptfs submodules:
|
||||
[FFmpeg](https://ffmpeg.org) is needed to record encrypted video:
|
||||
```
|
||||
$ cd app/libgocryptfs
|
||||
$ git submodule update --init
|
||||
$ cd app/ffmpeg
|
||||
$ git clone --depth=1 https://git.ffmpeg.org/ffmpeg.git
|
||||
```
|
||||
If you want CryFS support, initialize libcryfs submodules:
|
||||
If you want Gocryptfs support, you need to download OpenSSL:
|
||||
```
|
||||
$ cd ../libgocryptfs
|
||||
$ wget https://www.openssl.org/source/openssl-1.1.1t.tar.gz
|
||||
```
|
||||
Verify OpenSSL signature:
|
||||
```
|
||||
$ wget https://www.openssl.org/source/openssl-1.1.1t.tar.gz.asc
|
||||
$ gpg --verify openssl-1.1.1t.tar.gz.asc openssl-1.1.1t.tar.gz
|
||||
```
|
||||
Continue **ONLY** if the signature is **VALID**.
|
||||
```
|
||||
$ tar -xzf openssl-1.1.1t.tar.gz
|
||||
```
|
||||
If you want CryFS support, initialize libcryfs:
|
||||
```
|
||||
$ cd app/libcryfs
|
||||
$ git submodule update --init
|
||||
$ git submodule update --depth=1 --init
|
||||
```
|
||||
|
||||
# Build
|
||||
@ -56,33 +67,31 @@ Retrieve your Android NDK installation path, usually something like `/home/\<use
|
||||
```
|
||||
$ export ANDROID_NDK_HOME="<your ndk path>"
|
||||
```
|
||||
If you know your CPU ABI, you can specify it to build scripts in order to speed up compilation time. If you don't know it, or want to build for all ABIs, just leave the field blank.
|
||||
|
||||
Start by compiling FFmpeg:
|
||||
```
|
||||
$ cd app/ffmpeg
|
||||
$ ./build.sh [<ABI>]
|
||||
$ ./build.sh ffmpeg
|
||||
```
|
||||
## libgocryptfs
|
||||
This step is only required if you want Gocryptfs support.
|
||||
```
|
||||
$ cd app/libgocryptfs
|
||||
$ ./build.sh [<ABI>]
|
||||
$ OPENSSL_PATH="./openssl-1.1.1t" ./build.sh
|
||||
```
|
||||
## Compile APKs
|
||||
Gradle build libgocryptfs and libcryfs by default.
|
||||
|
||||
To build DroidFS without Gocryptfs support, run:
|
||||
```
|
||||
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableGocryptfs=true
|
||||
$ ./gradlew assembleRelease -PdisableGocryptfs=true
|
||||
```
|
||||
To build DroidFS without CryFS support, run:
|
||||
```
|
||||
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableCryFS=true
|
||||
$ ./gradlew assembleRelease -PdisableCryFS=true
|
||||
```
|
||||
If you want to build DroidFS with support for both Gocryptfs and CryFS, just run:
|
||||
```
|
||||
$ ./gradlew assembleRelease [-Pabi=<ABI>]
|
||||
$ ./gradlew assembleRelease
|
||||
```
|
||||
|
||||
# Sign APKs
|
||||
|
76
README.md
76
README.md
@ -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.dedyn.io). 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.fr.to). 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,47 +28,34 @@ 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 planned features, see [TODO.md](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/TODO.md).
|
||||
_For upcoming 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><b>Allow screenshots:</b>
|
||||
|
||||
<li><h4>Allow screenshots:</h4>
|
||||
Disable the secure flag of DroidFS activities. This will allow you to take screenshots from the app, but will also allow other apps to record the screen while using DroidFS.
|
||||
|
||||
Note: apps with root access don't care about this flag: they can take screenshots or record the screen of any app without any permissions.</li>
|
||||
<li><b>Allow exporting files:</b>
|
||||
|
||||
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.</li>
|
||||
<li><b>Allow sharing files via the android share menu⁽¹⁾:</b>
|
||||
|
||||
Decrypt and share file with other apps. These apps could save and send the files thus shared.</li>
|
||||
<li><b>Allow saving password hash using fingerprint:</b>
|
||||
|
||||
Generate an AES-256 GCM key in the Android Keystore (protected by fingerprint authentication), then use it to encrypt the volume password hash and store it to the DroidFS internal storage. This require Android v6.0+. If your device is not encrypted, extracting the encryption key with physical access may be possible.</li>
|
||||
<li><b>Disable volume auto-locking:</b> (previously called <i>"Keep volumes open when the app goes in background"</i>)
|
||||
|
||||
Don't close open volumes when you leave the app. Anyone going back to the application could have access to open volumes. Cryptographic secrets are kept in memory for an undefined amount of time.</li>
|
||||
<li><b>Keep volumes open:</b>
|
||||
(Different from the old <i>"Keep volumes open when the app goes in background"</i>. Yes it's confusing, sorry)
|
||||
|
||||
Keep the app running as a [foreground service](https://developer.android.com/develop/background-work/services/foreground-services) to maintain volumes open, even when the app is removed from recent tasks.
|
||||
|
||||
This avoid the app from being killed by the system during file operations or while accessing exposed volumes, but this mean cryptographic secrets stay in memory for an undefined amount of time.</li>
|
||||
<li><b>Allow opening files with other applications⁽¹⁾:</b>
|
||||
|
||||
Decrypt and open file using external apps. These apps could save and send the files thus opened.</li>
|
||||
<li><b>Expose open volumes⁽¹⁾:</b>
|
||||
|
||||
Allow open volumes to be browsed in the system file explorer (<a href="https://developer.android.com/guide/topics/providers/document-provider">DocumentProvider</a> API). Encrypted files can then be selected from other applications, potentially with permanent access. This feature requires <i>"Disable volume auto-locking"</i>, and works more reliably when <i>"Keep volumes open"</i> is also enabled.</li>
|
||||
<li><b>Grant write access:</b>
|
||||
|
||||
Files opened with another applications can be modified by them. This applies to both previous unsafe features.</li>
|
||||
Note: apps with root access don't care about this flag: they can take screenshots or record the screen of any app without any permissions.
|
||||
</li>
|
||||
<li><h4>Allow opening files with other applications*:</h4>
|
||||
Decrypt and open file using external apps. These apps could save and send the files thus opened.
|
||||
</li>
|
||||
<li><h4>Allow exporting files:</h4>
|
||||
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.
|
||||
</li>
|
||||
<li><h4>Allow sharing files via the android share menu*:</h4>
|
||||
Decrypt and share file with other apps. These apps could save and send the files thus shared.
|
||||
</li>
|
||||
<li><h4>Keep volume open when the app goes in background:</h4>
|
||||
Don't close the volume when you leave the app but keep running it in the background. Anyone going back to the activity could have access to the volume.
|
||||
</li>
|
||||
<li><h4>Allow saving password hash using fingerprint:</h4>
|
||||
Generate an AES-256 GCM key in the Android Keystore (protected by fingerprint authentication), then use it to encrypt the volume password hash and store it to the DroidFS internal storage. This require Android v6.0+. If your device is not encrypted, extracting the encryption key with physical access may be possible.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
⁽¹⁾: These features can work in two ways: temporarily writing the plain file to disk (DroidFS internal storage) or sharing it via memory. By default, DroidFS will choose to keep the file only in memory as it's more secure, but will fallback to disk export if the file is too large to be held in memory. This behavior can be changed with the *"Export method"* parameter in the settings. Please note that some applications require the file to be stored on disk, and therefore do not work with memory-exported files.
|
||||
* Features requiring temporary writing of the plain file to disk (DroidFS internal storage). This file could be read by apps with root access or by physical access if your device is not encrypted.
|
||||
|
||||
# Download
|
||||
<a href="https://f-droid.org/packages/sushi.hardcore.droidfs">
|
||||
@ -96,14 +83,23 @@ 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.
|
||||
|
||||
- **Read & write access to shared storage**: Required to access volumes located on shared storage.
|
||||
- **Biometric/Fingerprint hardware**: Required to encrypt/decrypt password hashes using a fingerprint protected key.
|
||||
- **Camera**: Required to take encrypted photos or videos directly from the app.
|
||||
- **Record audio**: Required if you want sound on video recorded with DroidFS.
|
||||
- **Notifications**: Used to report file operations progress and notify about volumes kept open.
|
||||
<ul>
|
||||
<li><h4>Read & write access to shared storage:</h4>
|
||||
Required 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>
|
||||
|
||||
# Limitations
|
||||
DroidFS works as a wrapper around modified versions of the original encrypted container implementations ([libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) and [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs)). These programs were designed to run on standard x86 Linux systems: they access the underlying file system with file paths and syscalls. However, on Android, you can't access files from other applications using file paths. Instead, one has to use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. Obviously, neither Gocryptfs nor CryFS support this API. As a result, DroidFS cannot open volumes provided by other applications (such as cloud storage clients). If you want to synchronize your volumes on a cloud, the cloud application must synchronize the encrypted directory from disk.
|
||||
DroidFS works as a wrapper around modified versions of the original encrypted container implementations ([libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) and [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs)). These programs were designed to run on standard x86 Linux systems: they access the underlying file system with file paths and syscalls. However, on Android, you can't access files from other applications using file paths. Instead, one has to use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. Obviously, neither Gocryptfs nor CryFS support this API. As a result, DroidFS cannot open volumes provided by other applications (such as cloud storage clients), nor can it allow other applications to access encrypted volumes once opened.
|
||||
|
||||
Due to Android's storage restrictions, encrypted volumes located on SD cards must be placed under `/Android/data/sushi.hardcore.droidfs/` if you want DroidFS to be able to modify them.
|
||||
|
||||
|
7
TODO.md
7
TODO.md
@ -8,9 +8,8 @@ Here's a list of features that it would be nice to have in DroidFS. As this is a
|
||||
|
||||
## UX
|
||||
- File associations editor
|
||||
- Discovery before exporting
|
||||
- Making discovery before file operations optional
|
||||
- Modifiable scrypt parameters
|
||||
- Optional discovery before file operations
|
||||
- Modifiable CryFS scrypt parameters
|
||||
- Alert dialog showing details of file operations
|
||||
- Internal file browser to select volumes
|
||||
|
||||
@ -19,6 +18,8 @@ Here's a list of features that it would be nice to have in DroidFS. As this is a
|
||||
- [fscrypt](https://www.kernel.org/doc/html/latest/filesystems/fscrypt.html): filesystem encryption at the kernel level
|
||||
|
||||
## Health
|
||||
- F-Droid ABI split
|
||||
- OpenSSL & FFmpeg as git submodules (useful for F-Droid)
|
||||
- Remove all android:configChanges from AndroidManifest.xml
|
||||
- More efficient thumbnails cache
|
||||
- Guide for translators
|
||||
|
@ -13,40 +13,31 @@ if (hasProperty("disableGocryptfs")) {
|
||||
ext.disableGocryptfs = false
|
||||
}
|
||||
|
||||
if (hasProperty("nosplits")) {
|
||||
ext.splits = false
|
||||
} else {
|
||||
ext.splits = true
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 34
|
||||
ndkVersion '25.2.9519653'
|
||||
compileSdkVersion 33
|
||||
buildToolsVersion "33.0.0"
|
||||
ndkVersion "25.1.8937393"
|
||||
namespace "sushi.hardcore.droidfs"
|
||||
|
||||
compileOptions {
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
def abiCodes = [ "x86": 1, "x86_64": 2, "armeabi-v7a": 3, "arm64-v8a": 4]
|
||||
|
||||
defaultConfig {
|
||||
applicationId "sushi.hardcore.droidfs"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 34
|
||||
versionCode 37
|
||||
versionName "2.2.0"
|
||||
targetSdkVersion 32
|
||||
versionCode 32
|
||||
versionName "2.0.2"
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset() // fix unknown bug (https://ru.stackoverflow.com/questions/1557805/abis-armeabi-mips-mips64-riscv64-are-not-supported-for-platform)
|
||||
if (project.hasProperty("abi")) {
|
||||
include project.getProperty("abi")
|
||||
} else {
|
||||
abiCodes.keySet().each { abi -> include abi }
|
||||
universalApk !project.hasProperty("nouniversal")
|
||||
}
|
||||
}
|
||||
ndk {
|
||||
abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
|
||||
}
|
||||
|
||||
externalNativeBuild.cmake {
|
||||
@ -59,25 +50,23 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
if (project.ext.splits) {
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all { variant ->
|
||||
variant.resValue "string", "versionName", variant.versionName
|
||||
buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}"
|
||||
buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@ -109,33 +98,34 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation project(":libpdfviewer:app")
|
||||
implementation 'androidx.core:core-ktx:1.13.1'
|
||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
||||
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||
def lifecycle_version = "2.8.3"
|
||||
def lifecycle_version = "2.6.1"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
|
||||
|
||||
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||
implementation "androidx.sqlite:sqlite-ktx:2.3.1"
|
||||
implementation "androidx.preference:preference-ktx:1.2.0"
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||
implementation 'com.google.android.material:material:1.9.0'
|
||||
implementation 'com.github.bumptech.glide:glide:4.15.1'
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
def media3_version = "1.3.1"
|
||||
implementation "androidx.media3:media3-exoplayer:$media3_version"
|
||||
implementation "androidx.media3:media3-ui:$media3_version"
|
||||
implementation "androidx.media3:media3-datasource:$media3_version"
|
||||
def exoplayer_version = "2.18.6"
|
||||
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
|
||||
implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
|
||||
|
||||
def camerax_version = "1.3.4"
|
||||
implementation "androidx.concurrent:concurrent-futures:1.1.0"
|
||||
|
||||
def camerax_version = "1.3.0-alpha06"
|
||||
implementation "androidx.camera:camera-camera2:$camerax_version"
|
||||
implementation "androidx.camera:camera-lifecycle:$camerax_version"
|
||||
implementation "androidx.camera:camera-view:$camerax_version"
|
||||
implementation "androidx.camera:camera-extensions:$camerax_version"
|
||||
|
||||
// dependencies needed by CameraX patch
|
||||
implementation "androidx.concurrent:concurrent-futures:1.2.0"
|
||||
def autoValueVersion = '1.10.4'
|
||||
def autoValueVersion = "1.10.1"
|
||||
implementation "com.google.auto.value:auto-value-annotations:$autoValueVersion"
|
||||
annotationProcessor "com.google.auto.value:auto-value:$autoValueVersion"
|
||||
}
|
||||
|
1
app/ffmpeg/.gitignore
vendored
1
app/ffmpeg/.gitignore
vendored
@ -1 +1,2 @@
|
||||
ffmpeg
|
||||
build
|
||||
|
@ -1,13 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z ${ANDROID_NDK_HOME+x} ]; then
|
||||
echo "Error: \$ANDROID_NDK_HOME is not defined." >&2
|
||||
exit 1
|
||||
elif [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <FFmpeg source directory> [<ABI>]" >&2
|
||||
exit 1
|
||||
else
|
||||
cd "$(dirname "$0")"
|
||||
FFMPEG_DIR="ffmpeg"
|
||||
FFMPEG_DIR=$1
|
||||
compile_for_arch() {
|
||||
echo "Compiling for $1..."
|
||||
case $1 in
|
||||
@ -29,8 +29,7 @@ else
|
||||
ARCH="arm"
|
||||
;;
|
||||
esac
|
||||
(cd $FFMPEG_DIR
|
||||
make clean || true
|
||||
(cd $FFMPEG_DIR && make clean;
|
||||
./configure \
|
||||
--cc="$CFN" \
|
||||
--cxx="$CFN++" \
|
||||
@ -74,19 +73,22 @@ else
|
||||
--disable-audiotoolbox \
|
||||
--disable-appkit \
|
||||
--disable-alsa \
|
||||
--disable-debug
|
||||
make -j "$(nproc --all)" >/dev/null)
|
||||
mkdir -p "build/$1/libavformat" "build/$1/libavcodec" "build/$1/libavutil"
|
||||
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so "build/$1/libavformat"
|
||||
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so "build/$1/libavcodec"
|
||||
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so "build/$1/libavutil"
|
||||
--disable-debug \
|
||||
>/dev/null &&
|
||||
make -j $(nproc --all) >/dev/null) &&
|
||||
mkdir -p build/$1/libavformat build/$1/libavcodec build/$1/libavutil &&
|
||||
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so build/$1/libavformat &&
|
||||
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so build/$1/libavcodec &&
|
||||
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so build/$1/libavutil ||
|
||||
exit 1
|
||||
}
|
||||
|
||||
export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
|
||||
if [ $# -eq 1 ]; then
|
||||
compile_for_arch "$1"
|
||||
if [ $# -eq 2 ]; then
|
||||
compile_for_arch $2
|
||||
else
|
||||
for abi in "x86_64" "x86" "arm64-v8a" "armeabi-v7a"; do
|
||||
declare -a ABIs=("x86_64" "x86" "arm64-v8a" "armeabi-v7a")
|
||||
for abi in ${ABIs[@]}; do
|
||||
compile_for_arch $abi
|
||||
done
|
||||
fi
|
||||
|
@ -1 +0,0 @@
|
||||
Subproject commit af25a4bfd2503caf3ee485b27b99b620302f5718
|
@ -1 +1 @@
|
||||
Subproject commit cd0af7088066f870f12eceed9836bde897f1d164
|
||||
Subproject commit 3c56f86d86afacaf4a07ae77aa3d146764d587ec
|
@ -1 +1 @@
|
||||
Subproject commit b221d4cf3c70edc711169a769a476802d2577b2b
|
||||
Subproject commit ab3e7886767d31f32baebcd72ebe5f098a70d65b
|
29
app/proguard-rules.pro
vendored
29
app/proguard-rules.pro
vendored
@ -1,4 +1,24 @@
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keep class sushi.hardcore.droidfs.SettingsActivity$**
|
||||
-keep class sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
@ -9,10 +29,3 @@
|
||||
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;
|
||||
}
|
||||
|
@ -4,9 +4,6 @@
|
||||
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" />
|
||||
@ -56,13 +53,11 @@
|
||||
<activity android:name=".file_viewers.AudioPlayer" android:configChanges="screenSize|orientation" />
|
||||
<activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" />
|
||||
<activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
|
||||
<activity android:name=".LogcatActivity"/>
|
||||
|
||||
<service android:name=".KeepAliveService" android:exported="false" android:foregroundServiceType="dataSync" />
|
||||
<service android:name=".ClosingService" android:exported="false" android:stopWithTask="false"/>
|
||||
<service android:name=".file_operations.FileOperationService" android:exported="false" android:foregroundServiceType="dataSync"/>
|
||||
<service android:name=".WiperService" android:exported="false" android:stopWithTask="false"/>
|
||||
<service android:name=".file_operations.FileOperationService" android:exported="false"/>
|
||||
|
||||
<receiver android:name=".NotificationBroadcastReceiver" android:exported="false">
|
||||
<receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="file_operation_cancel"/>
|
||||
</intent-filter>
|
||||
|
@ -58,8 +58,8 @@ public final class SucklessPendingRecording {
|
||||
private final OutputOptions mOutputOptions;
|
||||
private Consumer<VideoRecordEvent> mEventListener;
|
||||
private Executor mListenerExecutor;
|
||||
|
||||
private boolean mAudioEnabled = false;
|
||||
private boolean mIsPersistent = false;
|
||||
|
||||
SucklessPendingRecording(@NonNull Context context, @NonNull SucklessRecorder recorder,
|
||||
@NonNull OutputOptions options) {
|
||||
@ -104,10 +104,6 @@ public final class SucklessPendingRecording {
|
||||
return mAudioEnabled;
|
||||
}
|
||||
|
||||
boolean isPersistent() {
|
||||
return mIsPersistent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables audio to be recorded for this recording.
|
||||
*
|
||||
@ -143,69 +139,6 @@ public final class SucklessPendingRecording {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the recording to be a persistent recording.
|
||||
*
|
||||
* <p>A persistent recording will only be stopped by explicitly calling
|
||||
* {@link Recording#stop()} or {@link Recording#close()} and will ignore events that would
|
||||
* normally cause recording to stop, such as lifecycle events or explicit unbinding of a
|
||||
* {@link VideoCapture} use case that the recording's {@link Recorder} is attached to.
|
||||
*
|
||||
* <p>Even though lifecycle events or explicit unbinding use cases won't stop a persistent
|
||||
* recording, it will still stop the camera from producing data, resulting in the in-progress
|
||||
* persistent recording stopping getting data until the camera stream is activated again. For
|
||||
* example, when the activity goes into background, the recording will keep waiting for new
|
||||
* data to be recorded until the activity is back to foreground.
|
||||
*
|
||||
* <p>A {@link Recorder} instance is recommended to be associated with a single
|
||||
* {@link VideoCapture} instance, especially when using persistent recording. Otherwise, there
|
||||
* might be unexpected behavior. Any in-progress persistent recording created from the same
|
||||
* {@link Recorder} should be stopped before starting a new recording, even if the
|
||||
* {@link Recorder} is associated with a different {@link VideoCapture}.
|
||||
*
|
||||
* <p>To switch to a different camera stream while a recording is in progress, first create
|
||||
* the recording as persistent recording, then rebind the {@link VideoCapture} it's
|
||||
* associated with to a different camera. The implementation may be like:
|
||||
* <pre>{@code
|
||||
* // Prepare the Recorder and VideoCapture, then bind the VideoCapture to the back camera.
|
||||
* Recorder recorder = Recorder.Builder().build();
|
||||
* VideoCapture videoCapture = VideoCapture.withOutput(recorder);
|
||||
* cameraProvider.bindToLifecycle(
|
||||
* lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, videoCapture);
|
||||
*
|
||||
* // Prepare the persistent recording and start it.
|
||||
* Recording recording = recorder
|
||||
* .prepareRecording(context, outputOptions)
|
||||
* .asPersistentRecording()
|
||||
* .start(eventExecutor, eventListener);
|
||||
*
|
||||
* // Record from the back camera for a period of time.
|
||||
*
|
||||
* // Rebind the VideoCapture to the front camera.
|
||||
* cameraProvider.unbindAll();
|
||||
* cameraProvider.bindToLifecycle(
|
||||
* lifecycleOwner, CameraSelector.DEFAULT_FRONT_CAMERA, videoCapture);
|
||||
*
|
||||
* // Record from the front camera for a period of time.
|
||||
*
|
||||
* // Stop the recording explicitly.
|
||||
* recording.stop();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The audio data will still be recorded after the {@link VideoCapture} is unbound.
|
||||
* {@link Recording#pause() Pause} the recording first and {@link Recording#resume() resume} it
|
||||
* later to stop recording audio while rebinding use cases.
|
||||
*
|
||||
* <p>If the recording is unable to receive data from the new camera, possibly because of
|
||||
* incompatible surface combination, an exception will be thrown when binding to lifecycle.
|
||||
*/
|
||||
@ExperimentalPersistentRecording
|
||||
@NonNull
|
||||
public SucklessPendingRecording asPersistentRecording() {
|
||||
mIsPersistent = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the recording, making it an active recording.
|
||||
*
|
||||
@ -226,13 +159,7 @@ public final class SucklessPendingRecording {
|
||||
*
|
||||
* <p>If the returned {@link SucklessRecording} is garbage collected, the recording will be
|
||||
* automatically stopped. A reference to the active recording must be maintained as long as
|
||||
* the recording needs to be active. If the recording is garbage collected, the
|
||||
* {@link VideoRecordEvent.Finalize} event will contain error
|
||||
* {@link VideoRecordEvent.Finalize#ERROR_RECORDING_GARBAGE_COLLECTED}.
|
||||
*
|
||||
* <p>The {@link Recording} will be stopped automatically if the {@link VideoCapture} its
|
||||
* {@link Recorder} is attached to is unbound unless it's created
|
||||
* {@link #asPersistentRecording() as a persistent recording}.
|
||||
* the recording needs to be active.
|
||||
*
|
||||
* @throws IllegalStateException if the associated Recorder currently has an unfinished
|
||||
* active recording.
|
||||
|
@ -16,7 +16,6 @@
|
||||
|
||||
package androidx.camera.video;
|
||||
|
||||
import static androidx.camera.video.AudioStats.AUDIO_AMPLITUDE_NONE;
|
||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED;
|
||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED;
|
||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED;
|
||||
@ -55,8 +54,6 @@ import androidx.annotation.RequiresPermission;
|
||||
import androidx.annotation.RestrictTo;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.camera.core.AspectRatio;
|
||||
import androidx.camera.core.CameraInfo;
|
||||
import androidx.camera.core.DynamicRange;
|
||||
import androidx.camera.core.Logger;
|
||||
import androidx.camera.core.SurfaceRequest;
|
||||
import androidx.camera.core.impl.MutableStateObservable;
|
||||
@ -79,7 +76,7 @@ import androidx.camera.video.internal.compat.Api26Impl;
|
||||
import androidx.camera.video.internal.compat.quirk.DeactivateEncoderSurfaceBeforeStopEncoderQuirk;
|
||||
import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
|
||||
import androidx.camera.video.internal.compat.quirk.EncoderNotUsePersistentInputSurfaceQuirk;
|
||||
import androidx.camera.video.internal.config.AudioMimeInfo;
|
||||
import androidx.camera.video.internal.config.MimeInfo;
|
||||
import androidx.camera.video.internal.encoder.AudioEncoderConfig;
|
||||
import androidx.camera.video.internal.encoder.BufferCopiedEncodedData;
|
||||
import androidx.camera.video.internal.encoder.EncodeException;
|
||||
@ -343,14 +340,10 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Members only accessed on mSequentialExecutor //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
RecordingRecord mInProgressRecording = null;
|
||||
private RecordingRecord mInProgressRecording = null;
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
boolean mInProgressRecordingStopping = false;
|
||||
@Nullable
|
||||
private SurfaceRequest.TransformationInfo mInProgressTransformationInfo = null;
|
||||
@Nullable
|
||||
private SurfaceRequest.TransformationInfo mSourceTransformationInfo = null;
|
||||
private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null;
|
||||
private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null;
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
final List<ListenableFuture<Void>> mEncodingFutures = new ArrayList<>();
|
||||
@ -431,15 +424,13 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
ScheduledFuture<?> mSourceNonStreamingTimeout = null;
|
||||
// The Recorder has to be reset first before being configured again.
|
||||
private boolean mNeedsResetBeforeNextStart = false;
|
||||
private boolean mNeedsReset = false;
|
||||
@NonNull
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
VideoEncoderSession mVideoEncoderSession;
|
||||
@Nullable
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
VideoEncoderSession mVideoEncoderSessionToRelease = null;
|
||||
double mAudioAmplitude = 0;
|
||||
private boolean mShouldSendResumeEvent = false;
|
||||
//--------------------------------------------------------------------------------------------//
|
||||
|
||||
SucklessRecorder(@Nullable Executor executor, @NonNull MediaSpec mediaSpec,
|
||||
@ -496,13 +487,6 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
mSequentialExecutor.execute(() -> onSourceStateChangedInternal(newState));
|
||||
}
|
||||
|
||||
@RestrictTo(RestrictTo.Scope.LIBRARY)
|
||||
@Override
|
||||
@NonNull
|
||||
public VideoCapabilities getMediaCapabilities(@NonNull CameraInfo cameraInfo) {
|
||||
return getVideoCapabilities(cameraInfo);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public SucklessPendingRecording prepareRecording(@NonNull Context context, @NonNull MuxerOutputOptions outputOptions) {
|
||||
return prepareRecordingInternal(context, outputOptions);
|
||||
@ -772,8 +756,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
}
|
||||
}
|
||||
|
||||
void stop(@NonNull SucklessRecording activeRecording, @VideoRecordError int error,
|
||||
@Nullable Throwable errorCause) {
|
||||
void stop(@NonNull SucklessRecording activeRecording) {
|
||||
RecordingRecord pendingRecordingToFinalize = null;
|
||||
synchronized (mLock) {
|
||||
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
|
||||
@ -818,7 +801,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
|
||||
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
|
||||
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
|
||||
explicitlyStopTimeUs, error, errorCause));
|
||||
explicitlyStopTimeUs, ERROR_NONE, null));
|
||||
break;
|
||||
case ERROR:
|
||||
// In an error state, the recording will already be finalized. Treat as a
|
||||
@ -828,13 +811,9 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
}
|
||||
|
||||
if (pendingRecordingToFinalize != null) {
|
||||
if (error == VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED) {
|
||||
Logger.e(TAG, "Recording was stopped due to recording being garbage collected "
|
||||
+ "before any valid data has been produced.");
|
||||
}
|
||||
finalizePendingRecording(pendingRecordingToFinalize, ERROR_NO_VALID_DATA,
|
||||
new RuntimeException("Recording was stopped before any data could be "
|
||||
+ "produced.", errorCause));
|
||||
+ "produced."));
|
||||
}
|
||||
}
|
||||
|
||||
@ -864,8 +843,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
recordingToFinalize.getOutputOptions(),
|
||||
RecordingStats.of(/*duration=*/0L,
|
||||
/*bytes=*/0L,
|
||||
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause,
|
||||
AUDIO_AMPLITUDE_NONE)),
|
||||
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause)),
|
||||
OutputResults.of(Uri.EMPTY),
|
||||
error,
|
||||
cause));
|
||||
@ -896,15 +874,14 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
// If we're inactive and have no active surface, we'll reset the encoder directly.
|
||||
// Otherwise, we'll wait for the active surface's surface request listener to
|
||||
// reset the encoder.
|
||||
requestReset(ERROR_SOURCE_INACTIVE, null, false);
|
||||
requestReset(ERROR_SOURCE_INACTIVE, null);
|
||||
} else {
|
||||
// The source becomes inactive, the incoming new surface request has to be cached
|
||||
// and be serviced after the Recorder is reset when receiving the previous
|
||||
// surface request complete callback.
|
||||
mNeedsResetBeforeNextStart = true;
|
||||
if (mInProgressRecording != null && !mInProgressRecording.isPersistent()) {
|
||||
// Stop the in progress recording with "source inactive" error if it's not a
|
||||
// persistent recording.
|
||||
mNeedsReset = true;
|
||||
if (mInProgressRecording != null) {
|
||||
// Stop any in progress recording with "source inactive" error
|
||||
onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE,
|
||||
null);
|
||||
}
|
||||
@ -928,8 +905,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
* the surface request complete callback first.
|
||||
*/
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause,
|
||||
boolean videoOnly) {
|
||||
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause) {
|
||||
boolean shouldReset = false;
|
||||
boolean shouldStop = false;
|
||||
synchronized (mLock) {
|
||||
@ -951,22 +927,14 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
case PAUSED:
|
||||
// Fall-through
|
||||
case RECORDING:
|
||||
Preconditions.checkState(mInProgressRecording != null, "In-progress recording"
|
||||
+ " shouldn't be null when in state " + mState);
|
||||
if (mActiveRecordingRecord != mInProgressRecording) {
|
||||
throw new AssertionError("In-progress recording does not match the active"
|
||||
+ " recording. Unable to reset encoder.");
|
||||
}
|
||||
// If there's an active persistent recording, reset the Recorder directly.
|
||||
// Otherwise, stop the recording first then release the Recorder at
|
||||
// onRecordingFinalized().
|
||||
if (isPersistentRecordingInProgress()) {
|
||||
shouldReset = true;
|
||||
} else {
|
||||
// If there's an active recording, stop it first then release the resources
|
||||
// at onRecordingFinalized().
|
||||
shouldStop = true;
|
||||
setState(State.RESETTING);
|
||||
}
|
||||
break;
|
||||
// Fall-through
|
||||
case STOPPING:
|
||||
// Already stopping. Set state to RESETTING so resources will be released once
|
||||
// onRecordingFinalized() runs.
|
||||
@ -981,17 +949,14 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
// These calls must not be posted to the executor to ensure they are executed inline on
|
||||
// the sequential executor and the state changes above are correctly handled.
|
||||
if (shouldReset) {
|
||||
if (videoOnly) {
|
||||
resetVideo();
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
} else if (shouldStop) {
|
||||
stopInternal(mInProgressRecording, Encoder.NO_TIMESTAMP, errorCode, errorCause);
|
||||
}
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
|
||||
private void configureInternal(@NonNull SurfaceRequest surfaceRequest,
|
||||
@NonNull Timebase videoSourceTimebase) {
|
||||
if (surfaceRequest.isServiced()) {
|
||||
@ -999,19 +964,16 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
return;
|
||||
}
|
||||
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
|
||||
(transformationInfo) -> mSourceTransformationInfo = transformationInfo);
|
||||
(transformationInfo) -> mSurfaceTransformationInfo = transformationInfo);
|
||||
Size surfaceSize = surfaceRequest.getResolution();
|
||||
// Fetch and cache nearest encoder profiles, if one exists.
|
||||
DynamicRange dynamicRange = surfaceRequest.getDynamicRange();
|
||||
VideoCapabilities capabilities = getVideoCapabilities(
|
||||
surfaceRequest.getCamera().getCameraInfo());
|
||||
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize,
|
||||
dynamicRange);
|
||||
LegacyVideoCapabilities capabilities =
|
||||
LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo());
|
||||
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize);
|
||||
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
|
||||
+ " for surface size " + surfaceSize);
|
||||
if (highestSupportedQuality != Quality.NONE) {
|
||||
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality,
|
||||
dynamicRange);
|
||||
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality);
|
||||
if (mResolvedEncoderProfiles == null) {
|
||||
throw new AssertionError("Camera advertised available quality but did not "
|
||||
+ "produce EncoderProfiles for advertised quality.");
|
||||
@ -1024,14 +986,9 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private void setupVideo(@NonNull SurfaceRequest request, @NonNull Timebase timebase) {
|
||||
safeToCloseVideoEncoder().addListener(() -> {
|
||||
if (request.isServiced()
|
||||
|| (mVideoEncoderSession.isConfiguredSurfaceRequest(request)
|
||||
&& !isPersistentRecordingInProgress())) {
|
||||
// Ignore the surface request if it's already serviced. Or the video encoder
|
||||
// session is already configured, unless there's a persistent recording is running.
|
||||
if (request.isServiced() || mVideoEncoderSession.isConfiguredSurfaceRequest(request)) {
|
||||
Logger.w(TAG, "Ignore the SurfaceRequest " + request + " isServiced: "
|
||||
+ request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession
|
||||
+ " has been configured with a persistent in-progress recording.");
|
||||
+ request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession);
|
||||
return;
|
||||
}
|
||||
VideoEncoderSession videoEncoderSession =
|
||||
@ -1063,12 +1020,6 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
}, mSequentialExecutor);
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
boolean isPersistentRecordingInProgress() {
|
||||
return mInProgressRecording != null && mInProgressRecording.isPersistent();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private ListenableFuture<Void> safeToCloseVideoEncoder() {
|
||||
@ -1104,9 +1055,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
|
||||
mVideoEncoderSessionToRelease = videoEncoderSession;
|
||||
setLatestSurface(null);
|
||||
// Only reset video if the in-progress recording is persistent.
|
||||
requestReset(ERROR_SOURCE_INACTIVE, null,
|
||||
isPersistentRecordingInProgress());
|
||||
requestReset(ERROR_SOURCE_INACTIVE, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -1121,14 +1070,17 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
void onConfigured() {
|
||||
RecordingRecord recordingToStart = null;
|
||||
RecordingRecord pendingRecordingToFinalize = null;
|
||||
boolean continuePersistentRecording = false;
|
||||
@VideoRecordError int error = ERROR_NONE;
|
||||
Throwable errorCause = null;
|
||||
boolean recordingPaused = false;
|
||||
boolean startRecordingPaused = false;
|
||||
synchronized (mLock) {
|
||||
switch (mState) {
|
||||
case IDLING:
|
||||
// Fall-through
|
||||
case RECORDING:
|
||||
// Fall-through
|
||||
case PAUSED:
|
||||
// Fall-through
|
||||
case RESETTING:
|
||||
throw new AssertionError(
|
||||
"Incorrectly invoke onConfigured() in state " + mState);
|
||||
@ -1138,15 +1090,6 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
+ "STOPPING state when it's not waiting for a new surface.");
|
||||
}
|
||||
break;
|
||||
case PAUSED:
|
||||
recordingPaused = true;
|
||||
// Fall-through
|
||||
case RECORDING:
|
||||
Preconditions.checkState(isPersistentRecordingInProgress(),
|
||||
"Unexpectedly invoke onConfigured() when there's a non-persistent "
|
||||
+ "in-progress recording");
|
||||
continuePersistentRecording = true;
|
||||
break;
|
||||
case CONFIGURING:
|
||||
setState(State.IDLING);
|
||||
break;
|
||||
@ -1155,7 +1098,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
"onConfigured() was invoked when the Recorder had encountered error");
|
||||
break;
|
||||
case PENDING_PAUSED:
|
||||
recordingPaused = true;
|
||||
startRecordingPaused = true;
|
||||
// Fall through
|
||||
case PENDING_RECORDING:
|
||||
if (mActiveRecordingRecord != null) {
|
||||
@ -1176,21 +1119,9 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
}
|
||||
}
|
||||
|
||||
if (continuePersistentRecording) {
|
||||
updateEncoderCallbacks(mInProgressRecording, true);
|
||||
mVideoEncoder.start();
|
||||
if (mShouldSendResumeEvent) {
|
||||
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume(
|
||||
mInProgressRecording.getOutputOptions(),
|
||||
getInProgressRecordingStats()));
|
||||
mShouldSendResumeEvent = false;
|
||||
}
|
||||
if (recordingPaused) {
|
||||
mVideoEncoder.pause();
|
||||
}
|
||||
} else if (recordingToStart != null) {
|
||||
if (recordingToStart != null) {
|
||||
// Start new active recording inline on sequential executor (but unlocked).
|
||||
startRecording(recordingToStart, recordingPaused);
|
||||
startRecording(recordingToStart, startRecordingPaused);
|
||||
} else if (pendingRecordingToFinalize != null) {
|
||||
finalizePendingRecording(pendingRecordingToFinalize, error, errorCause);
|
||||
}
|
||||
@ -1231,7 +1162,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
throws AudioSourceAccessException, InvalidConfigException {
|
||||
MediaSpec mediaSpec = getObservableData(mMediaSpec);
|
||||
// Resolve the audio mime info
|
||||
AudioMimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
|
||||
MimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
|
||||
Timebase audioSourceTimebase = Timebase.UPTIME;
|
||||
|
||||
// Select and create the audio source
|
||||
@ -1382,10 +1313,8 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
return;
|
||||
}
|
||||
|
||||
SurfaceRequest.TransformationInfo transformationInfo = mSourceTransformationInfo;
|
||||
if (transformationInfo != null) {
|
||||
setInProgressTransformationInfo(transformationInfo);
|
||||
mediaMuxer.setOrientationHint(transformationInfo.getRotationDegrees());
|
||||
if (mSurfaceTransformationInfo != null) {
|
||||
mediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees());
|
||||
}
|
||||
|
||||
mVideoTrackIndex = mediaMuxer.addTrack(mVideoOutputConfig.getMediaFormat());
|
||||
@ -1473,9 +1402,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
"The Recorder doesn't support recording with audio");
|
||||
}
|
||||
try {
|
||||
if (!mInProgressRecording.isPersistent() || mAudioEncoder == null) {
|
||||
setupAudio(recordingToStart);
|
||||
}
|
||||
setAudioState(AudioState.ENABLED);
|
||||
} catch (AudioSourceAccessException | InvalidConfigException e) {
|
||||
Logger.e(TAG, "Unable to create audio resource with error: ", e);
|
||||
@ -1492,7 +1419,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
break;
|
||||
}
|
||||
|
||||
updateEncoderCallbacks(recordingToStart, false);
|
||||
initEncoderAndAudioSourceCallbacks(recordingToStart);
|
||||
if (isAudioEnabled()) {
|
||||
mAudioSource.start(recordingToStart.isMuted());
|
||||
mAudioEncoder.start();
|
||||
@ -1505,17 +1432,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private void updateEncoderCallbacks(@NonNull RecordingRecord recordingToStart,
|
||||
boolean videoOnly) {
|
||||
// If there are uncompleted futures, cancel them first.
|
||||
if (!mEncodingFutures.isEmpty()) {
|
||||
ListenableFuture<List<Void>> listFuture = Futures.allAsList(mEncodingFutures);
|
||||
if (!listFuture.isDone()) {
|
||||
listFuture.cancel(true);
|
||||
}
|
||||
mEncodingFutures.clear();
|
||||
}
|
||||
|
||||
private void initEncoderAndAudioSourceCallbacks(@NonNull RecordingRecord recordingToStart) {
|
||||
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
||||
completer -> {
|
||||
mVideoEncoder.setEncoderCallback(new EncoderCallback() {
|
||||
@ -1611,7 +1528,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
return "videoEncodingFuture";
|
||||
}));
|
||||
|
||||
if (isAudioEnabled() && !videoOnly) {
|
||||
if (isAudioEnabled()) {
|
||||
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
||||
completer -> {
|
||||
Consumer<Throwable> audioErrorConsumer = throwable -> {
|
||||
@ -1651,11 +1568,6 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
audioErrorConsumer.accept(throwable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAmplitudeValue(double maxAmplitude) {
|
||||
mAudioAmplitude = maxAmplitude;
|
||||
}
|
||||
});
|
||||
|
||||
mAudioEncoder.setEncoderCallback(new EncoderCallback() {
|
||||
@ -1742,16 +1654,12 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Throwable t) {
|
||||
Preconditions.checkState(mInProgressRecording != null,
|
||||
"In-progress recording shouldn't be null");
|
||||
// If a persistent recording requires reconfiguring the video encoder,
|
||||
// the previous encoder future has to be canceled without finalizing the
|
||||
// in-progress recording.
|
||||
if (!mInProgressRecording.isPersistent()) {
|
||||
Logger.d(TAG, "Encodings end with error: " + t);
|
||||
finalizeInProgressRecording(mMediaMuxer == null ? ERROR_NO_VALID_DATA
|
||||
: ERROR_ENCODING_FAILED, t);
|
||||
}
|
||||
// If the media muxer hasn't been set up, assume the encoding fails
|
||||
// because of no valid data has been produced.
|
||||
finalizeInProgressRecording(
|
||||
mMediaMuxer == null ? ERROR_NO_VALID_DATA : ERROR_ENCODING_FAILED,
|
||||
t);
|
||||
}
|
||||
},
|
||||
// Can use direct executor since completers are always completed on sequential
|
||||
@ -1892,20 +1800,11 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
if (isAudioEnabled()) {
|
||||
mAudioEncoder.start();
|
||||
}
|
||||
// If a persistent recording is resumed immediately after the VideoCapture is rebound
|
||||
// to a camera, it's possible that the encoder hasn't been created yet. Then the
|
||||
// encoder will be started once it's initialized. So only start the encoder when it's
|
||||
// not null.
|
||||
if (mVideoEncoder != null) {
|
||||
mVideoEncoder.start();
|
||||
|
||||
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume(
|
||||
mInProgressRecording.getOutputOptions(),
|
||||
getInProgressRecordingStats()));
|
||||
} else {
|
||||
// Instead sending here, send the Resume event once the encoder is initialized
|
||||
// and started.
|
||||
mShouldSendResumeEvent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1999,12 +1898,13 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
mAudioEncoder = null;
|
||||
mAudioOutputConfig = null;
|
||||
}
|
||||
tryReleaseVideoEncoder();
|
||||
if (mAudioSource != null) {
|
||||
releaseCurrentAudioSource();
|
||||
}
|
||||
|
||||
setAudioState(AudioState.INITIALIZING);
|
||||
resetVideo();
|
||||
onReset();
|
||||
}
|
||||
|
||||
@SuppressWarnings("FutureReturnValueIgnored")
|
||||
@ -2026,8 +1926,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private void onResetVideo() {
|
||||
boolean shouldConfigure = true;
|
||||
private void onReset() {
|
||||
synchronized (mLock) {
|
||||
switch (mState) {
|
||||
case PENDING_PAUSED:
|
||||
@ -2040,10 +1939,6 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
case PAUSED:
|
||||
// Fall-through
|
||||
case RECORDING:
|
||||
if (isPersistentRecordingInProgress()) {
|
||||
shouldConfigure = false;
|
||||
break;
|
||||
}
|
||||
// Fall-through
|
||||
case IDLING:
|
||||
// Fall-through
|
||||
@ -2058,24 +1953,14 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
}
|
||||
}
|
||||
|
||||
mNeedsResetBeforeNextStart = false;
|
||||
mNeedsReset = false;
|
||||
|
||||
// If the latest surface request hasn't been serviced, use it to re-configure the Recorder.
|
||||
if (shouldConfigure && mLatestSurfaceRequest != null
|
||||
&& !mLatestSurfaceRequest.isServiced()) {
|
||||
if (mLatestSurfaceRequest != null && !mLatestSurfaceRequest.isServiced()) {
|
||||
configureInternal(mLatestSurfaceRequest, mVideoSourceTimebase);
|
||||
}
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private void resetVideo() {
|
||||
if (mVideoEncoder != null) {
|
||||
Logger.d(TAG, "Releasing video encoder.");
|
||||
tryReleaseVideoEncoder();
|
||||
}
|
||||
onResetVideo();
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private int internalAudioStateToAudioStatsState(@NonNull AudioState audioState) {
|
||||
switch (audioState) {
|
||||
@ -2178,9 +2063,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
mRecordingStopError = ERROR_UNKNOWN;
|
||||
mRecordingStopErrorCause = null;
|
||||
mAudioErrorCause = null;
|
||||
mAudioAmplitude = AUDIO_AMPLITUDE_NONE;
|
||||
clearPendingAudioRingBuffer();
|
||||
setInProgressTransformationInfo(null);
|
||||
|
||||
switch (mAudioState) {
|
||||
case IDLING:
|
||||
@ -2363,7 +2246,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
startRecordingPaused = true;
|
||||
// Fall-through
|
||||
case PENDING_RECORDING:
|
||||
if (mActiveRecordingRecord != null || mNeedsResetBeforeNextStart) {
|
||||
if (mActiveRecordingRecord != null || mNeedsReset) {
|
||||
// Active recording is still finalizing or the Recorder is expected to be
|
||||
// reset. Pending recording will be serviced in onRecordingFinalized() or
|
||||
// in onReset().
|
||||
@ -2478,8 +2361,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
@NonNull
|
||||
RecordingStats getInProgressRecordingStats() {
|
||||
return RecordingStats.of(mRecordingDurationNs, mRecordingBytes,
|
||||
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause,
|
||||
mAudioAmplitude));
|
||||
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause));
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@ -2533,7 +2415,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
if (streamState == null) {
|
||||
streamState = internalStateToStreamState(mState);
|
||||
}
|
||||
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState, mInProgressTransformationInfo));
|
||||
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState));
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
@ -2555,20 +2437,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
}
|
||||
Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + streamId);
|
||||
mStreamId = streamId;
|
||||
mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState),
|
||||
mInProgressTransformationInfo));
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
void setInProgressTransformationInfo(
|
||||
@Nullable SurfaceRequest.TransformationInfo transformationInfo) {
|
||||
Logger.d(TAG, "Update stream transformation info: " + transformationInfo);
|
||||
mInProgressTransformationInfo = transformationInfo;
|
||||
synchronized (mLock) {
|
||||
mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(mState),
|
||||
transformationInfo));
|
||||
}
|
||||
mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState)));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -2591,8 +2460,8 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
|
||||
if (mNonPendingState != state) {
|
||||
mNonPendingState = state;
|
||||
mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(state),
|
||||
mInProgressTransformationInfo));
|
||||
mStreamInfo.setState(
|
||||
StreamInfo.of(mStreamId, internalStateToStreamState(state)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2640,21 +2509,6 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
return defaultMuxerFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link VideoCapabilities} of Recorder with respect to input camera information.
|
||||
*
|
||||
* <p>{@link VideoCapabilities} provides methods to query supported dynamic ranges and
|
||||
* qualities. This information can be used for things like checking if HDR is supported for
|
||||
* configuring VideoCapture to record HDR video.
|
||||
*
|
||||
* @param cameraInfo info about the camera.
|
||||
* @return VideoCapabilities with respect to the input camera info.
|
||||
*/
|
||||
@NonNull
|
||||
public static VideoCapabilities getVideoCapabilities(@NonNull CameraInfo cameraInfo) {
|
||||
return RecorderVideoCapabilities.from(cameraInfo);
|
||||
}
|
||||
|
||||
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
|
||||
@AutoValue
|
||||
abstract static class RecordingRecord implements AutoCloseable {
|
||||
@ -2683,7 +2537,6 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
pendingRecording.getListenerExecutor(),
|
||||
pendingRecording.getEventListener(),
|
||||
pendingRecording.isAudioEnabled(),
|
||||
pendingRecording.isPersistent(),
|
||||
recordingId
|
||||
);
|
||||
}
|
||||
@ -2699,8 +2552,6 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
|
||||
abstract boolean hasAudioEnabled();
|
||||
|
||||
abstract boolean isPersistent();
|
||||
|
||||
abstract long getRecordingId();
|
||||
|
||||
/**
|
||||
@ -2931,12 +2782,7 @@ public final class SucklessRecorder implements VideoOutput {
|
||||
throw new AssertionError("One-time media muxer creation has already occurred for"
|
||||
+ " recording " + this);
|
||||
}
|
||||
|
||||
try {
|
||||
return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Failed to create MediaMuxer by " + e, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -21,7 +21,6 @@ import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
|
||||
import android.annotation.SuppressLint;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.RestrictTo;
|
||||
import androidx.camera.core.impl.utils.CloseGuardHelper;
|
||||
@ -57,15 +56,13 @@ public final class SucklessRecording implements AutoCloseable {
|
||||
private final SucklessRecorder mRecorder;
|
||||
private final long mRecordingId;
|
||||
private final OutputOptions mOutputOptions;
|
||||
private final boolean mIsPersistent;
|
||||
private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
|
||||
|
||||
SucklessRecording(@NonNull SucklessRecorder recorder, long recordingId, @NonNull OutputOptions options,
|
||||
boolean isPersistent, boolean finalizedOnCreation) {
|
||||
boolean finalizedOnCreation) {
|
||||
mRecorder = recorder;
|
||||
mRecordingId = recordingId;
|
||||
mOutputOptions = options;
|
||||
mIsPersistent = isPersistent;
|
||||
|
||||
if (finalizedOnCreation) {
|
||||
mIsClosed.set(true);
|
||||
@ -86,7 +83,6 @@ public final class SucklessRecording implements AutoCloseable {
|
||||
return new SucklessRecording(pendingRecording.getRecorder(),
|
||||
recordingId,
|
||||
pendingRecording.getOutputOptions(),
|
||||
pendingRecording.isPersistent(),
|
||||
/*finalizedOnCreation=*/false);
|
||||
}
|
||||
|
||||
@ -107,7 +103,6 @@ public final class SucklessRecording implements AutoCloseable {
|
||||
return new SucklessRecording(pendingRecording.getRecorder(),
|
||||
recordingId,
|
||||
pendingRecording.getOutputOptions(),
|
||||
pendingRecording.isPersistent(),
|
||||
/*finalizedOnCreation=*/true);
|
||||
}
|
||||
|
||||
@ -116,20 +111,6 @@ public final class SucklessRecording implements AutoCloseable {
|
||||
return mOutputOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this recording is a persistent recording.
|
||||
*
|
||||
* <p>A persistent recording will only be stopped by explicitly calling of
|
||||
* {@link Recording#stop()} and will ignore the lifecycle events or source state changes.
|
||||
* Users are responsible of stopping a persistent recording.
|
||||
*
|
||||
* @return {@code true} if the recording is a persistent recording, otherwise {@code false}.
|
||||
*/
|
||||
@ExperimentalPersistentRecording
|
||||
public boolean isPersistent() {
|
||||
return mIsPersistent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the current recording if active.
|
||||
*
|
||||
@ -215,7 +196,11 @@ public final class SucklessRecording implements AutoCloseable {
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null);
|
||||
mCloseGuard.close();
|
||||
if (mIsClosed.getAndSet(true)) {
|
||||
return;
|
||||
}
|
||||
mRecorder.stop(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -223,8 +208,7 @@ public final class SucklessRecording implements AutoCloseable {
|
||||
protected void finalize() throws Throwable {
|
||||
try {
|
||||
mCloseGuard.warnIfOpen();
|
||||
stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED,
|
||||
new RuntimeException("Recording stopped due to being garbage collected."));
|
||||
stop();
|
||||
} finally {
|
||||
super.finalize();
|
||||
}
|
||||
@ -250,14 +234,5 @@ public final class SucklessRecording implements AutoCloseable {
|
||||
public boolean isClosed() {
|
||||
return mIsClosed.get();
|
||||
}
|
||||
|
||||
private void stopWithError(@VideoRecordEvent.Finalize.VideoRecordError int error,
|
||||
@Nullable Throwable errorCause) {
|
||||
mCloseGuard.close();
|
||||
if (mIsClosed.getAndSet(true)) {
|
||||
return;
|
||||
}
|
||||
mRecorder.stop(this, error, errorCause);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,7 +35,6 @@ 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;
|
||||
|
||||
@ -1055,7 +1054,6 @@ 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;
|
||||
@ -1067,7 +1065,7 @@ public class SucklessEncoderImpl implements Encoder {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInputBufferAvailable(@NonNull MediaCodec mediaCodec, int index) {
|
||||
public void onInputBufferAvailable(MediaCodec mediaCodec, int index) {
|
||||
mEncoderExecutor.execute(() -> {
|
||||
if (mStopped) {
|
||||
Logger.w(mTag, "Receives input frame after codec is reset.");
|
||||
@ -1133,15 +1131,6 @@ 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;
|
||||
|
@ -5,7 +5,7 @@ Create the `new` folder if needed:
|
||||
mkdir -p new
|
||||
```
|
||||
|
||||
Put the new CameraX files from upstream (`androidx.camera.video.Recorder`, `androidx.camera.video.Recording`, `androidx.camera.video.PendingRecording` and `androidx.camera.video.internal.encoder.EncoderImpl`) in the `new` folder.
|
||||
Put new CameraX files from upstream in the `new` folder.
|
||||
|
||||
Perform the 3 way merge:
|
||||
```
|
||||
|
@ -35,7 +35,6 @@ 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,7 +1053,6 @@ 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;
|
||||
@ -1066,7 +1064,7 @@ public class EncoderImpl implements Encoder {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInputBufferAvailable(@NonNull MediaCodec mediaCodec, int index) {
|
||||
public void onInputBufferAvailable(MediaCodec mediaCodec, int index) {
|
||||
mEncoderExecutor.execute(() -> {
|
||||
if (mStopped) {
|
||||
Logger.w(mTag, "Receives input frame after codec is reset.");
|
||||
@ -1132,15 +1130,6 @@ 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;
|
||||
|
@ -56,8 +56,8 @@ public final class PendingRecording {
|
||||
private final OutputOptions mOutputOptions;
|
||||
private Consumer<VideoRecordEvent> mEventListener;
|
||||
private Executor mListenerExecutor;
|
||||
|
||||
private boolean mAudioEnabled = false;
|
||||
private boolean mIsPersistent = false;
|
||||
|
||||
PendingRecording(@NonNull Context context, @NonNull Recorder recorder,
|
||||
@NonNull OutputOptions options) {
|
||||
@ -102,10 +102,6 @@ public final class PendingRecording {
|
||||
return mAudioEnabled;
|
||||
}
|
||||
|
||||
boolean isPersistent() {
|
||||
return mIsPersistent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables audio to be recorded for this recording.
|
||||
*
|
||||
@ -141,69 +137,6 @@ public final class PendingRecording {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the recording to be a persistent recording.
|
||||
*
|
||||
* <p>A persistent recording will only be stopped by explicitly calling
|
||||
* {@link Recording#stop()} or {@link Recording#close()} and will ignore events that would
|
||||
* normally cause recording to stop, such as lifecycle events or explicit unbinding of a
|
||||
* {@link VideoCapture} use case that the recording's {@link Recorder} is attached to.
|
||||
*
|
||||
* <p>Even though lifecycle events or explicit unbinding use cases won't stop a persistent
|
||||
* recording, it will still stop the camera from producing data, resulting in the in-progress
|
||||
* persistent recording stopping getting data until the camera stream is activated again. For
|
||||
* example, when the activity goes into background, the recording will keep waiting for new
|
||||
* data to be recorded until the activity is back to foreground.
|
||||
*
|
||||
* <p>A {@link Recorder} instance is recommended to be associated with a single
|
||||
* {@link VideoCapture} instance, especially when using persistent recording. Otherwise, there
|
||||
* might be unexpected behavior. Any in-progress persistent recording created from the same
|
||||
* {@link Recorder} should be stopped before starting a new recording, even if the
|
||||
* {@link Recorder} is associated with a different {@link VideoCapture}.
|
||||
*
|
||||
* <p>To switch to a different camera stream while a recording is in progress, first create
|
||||
* the recording as persistent recording, then rebind the {@link VideoCapture} it's
|
||||
* associated with to a different camera. The implementation may be like:
|
||||
* <pre>{@code
|
||||
* // Prepare the Recorder and VideoCapture, then bind the VideoCapture to the back camera.
|
||||
* Recorder recorder = Recorder.Builder().build();
|
||||
* VideoCapture videoCapture = VideoCapture.withOutput(recorder);
|
||||
* cameraProvider.bindToLifecycle(
|
||||
* lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, videoCapture);
|
||||
*
|
||||
* // Prepare the persistent recording and start it.
|
||||
* Recording recording = recorder
|
||||
* .prepareRecording(context, outputOptions)
|
||||
* .asPersistentRecording()
|
||||
* .start(eventExecutor, eventListener);
|
||||
*
|
||||
* // Record from the back camera for a period of time.
|
||||
*
|
||||
* // Rebind the VideoCapture to the front camera.
|
||||
* cameraProvider.unbindAll();
|
||||
* cameraProvider.bindToLifecycle(
|
||||
* lifecycleOwner, CameraSelector.DEFAULT_FRONT_CAMERA, videoCapture);
|
||||
*
|
||||
* // Record from the front camera for a period of time.
|
||||
*
|
||||
* // Stop the recording explicitly.
|
||||
* recording.stop();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The audio data will still be recorded after the {@link VideoCapture} is unbound.
|
||||
* {@link Recording#pause() Pause} the recording first and {@link Recording#resume() resume} it
|
||||
* later to stop recording audio while rebinding use cases.
|
||||
*
|
||||
* <p>If the recording is unable to receive data from the new camera, possibly because of
|
||||
* incompatible surface combination, an exception will be thrown when binding to lifecycle.
|
||||
*/
|
||||
@ExperimentalPersistentRecording
|
||||
@NonNull
|
||||
public PendingRecording asPersistentRecording() {
|
||||
mIsPersistent = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the recording, making it an active recording.
|
||||
*
|
||||
@ -224,13 +157,7 @@ public final class PendingRecording {
|
||||
*
|
||||
* <p>If the returned {@link Recording} is garbage collected, the recording will be
|
||||
* automatically stopped. A reference to the active recording must be maintained as long as
|
||||
* the recording needs to be active. If the recording is garbage collected, the
|
||||
* {@link VideoRecordEvent.Finalize} event will contain error
|
||||
* {@link VideoRecordEvent.Finalize#ERROR_RECORDING_GARBAGE_COLLECTED}.
|
||||
*
|
||||
* <p>The {@link Recording} will be stopped automatically if the {@link VideoCapture} its
|
||||
* {@link Recorder} is attached to is unbound unless it's created
|
||||
* {@link #asPersistentRecording() as a persistent recording}.
|
||||
* the recording needs to be active.
|
||||
*
|
||||
* @throws IllegalStateException if the associated Recorder currently has an unfinished
|
||||
* active recording.
|
||||
|
@ -16,7 +16,6 @@
|
||||
|
||||
package androidx.camera.video;
|
||||
|
||||
import static androidx.camera.video.AudioStats.AUDIO_AMPLITUDE_NONE;
|
||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED;
|
||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED;
|
||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED;
|
||||
@ -58,8 +57,6 @@ import androidx.annotation.RequiresPermission;
|
||||
import androidx.annotation.RestrictTo;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.camera.core.AspectRatio;
|
||||
import androidx.camera.core.CameraInfo;
|
||||
import androidx.camera.core.DynamicRange;
|
||||
import androidx.camera.core.Logger;
|
||||
import androidx.camera.core.SurfaceRequest;
|
||||
import androidx.camera.core.impl.MutableStateObservable;
|
||||
@ -82,7 +79,7 @@ import androidx.camera.video.internal.compat.Api26Impl;
|
||||
import androidx.camera.video.internal.compat.quirk.DeactivateEncoderSurfaceBeforeStopEncoderQuirk;
|
||||
import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
|
||||
import androidx.camera.video.internal.compat.quirk.EncoderNotUsePersistentInputSurfaceQuirk;
|
||||
import androidx.camera.video.internal.config.AudioMimeInfo;
|
||||
import androidx.camera.video.internal.config.MimeInfo;
|
||||
import androidx.camera.video.internal.encoder.AudioEncoderConfig;
|
||||
import androidx.camera.video.internal.encoder.BufferCopiedEncodedData;
|
||||
import androidx.camera.video.internal.encoder.EncodeException;
|
||||
@ -346,14 +343,10 @@ public final class Recorder implements VideoOutput {
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Members only accessed on mSequentialExecutor //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
RecordingRecord mInProgressRecording = null;
|
||||
private RecordingRecord mInProgressRecording = null;
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
boolean mInProgressRecordingStopping = false;
|
||||
@Nullable
|
||||
private SurfaceRequest.TransformationInfo mInProgressTransformationInfo = null;
|
||||
@Nullable
|
||||
private SurfaceRequest.TransformationInfo mSourceTransformationInfo = null;
|
||||
private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null;
|
||||
private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null;
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
final List<ListenableFuture<Void>> mEncodingFutures = new ArrayList<>();
|
||||
@ -434,15 +427,13 @@ public final class Recorder implements VideoOutput {
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
ScheduledFuture<?> mSourceNonStreamingTimeout = null;
|
||||
// The Recorder has to be reset first before being configured again.
|
||||
private boolean mNeedsResetBeforeNextStart = false;
|
||||
private boolean mNeedsReset = false;
|
||||
@NonNull
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
VideoEncoderSession mVideoEncoderSession;
|
||||
@Nullable
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
VideoEncoderSession mVideoEncoderSessionToRelease = null;
|
||||
double mAudioAmplitude = 0;
|
||||
private boolean mShouldSendResumeEvent = false;
|
||||
//--------------------------------------------------------------------------------------------//
|
||||
|
||||
Recorder(@Nullable Executor executor, @NonNull MediaSpec mediaSpec,
|
||||
@ -499,13 +490,6 @@ public final class Recorder implements VideoOutput {
|
||||
mSequentialExecutor.execute(() -> onSourceStateChangedInternal(newState));
|
||||
}
|
||||
|
||||
@RestrictTo(RestrictTo.Scope.LIBRARY)
|
||||
@Override
|
||||
@NonNull
|
||||
public VideoCapabilities getMediaCapabilities(@NonNull CameraInfo cameraInfo) {
|
||||
return getVideoCapabilities(cameraInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a recording that will be saved to a {@link File}.
|
||||
*
|
||||
@ -862,8 +846,7 @@ public final class Recorder implements VideoOutput {
|
||||
}
|
||||
}
|
||||
|
||||
void stop(@NonNull Recording activeRecording, @VideoRecordError int error,
|
||||
@Nullable Throwable errorCause) {
|
||||
void stop(@NonNull Recording activeRecording) {
|
||||
RecordingRecord pendingRecordingToFinalize = null;
|
||||
synchronized (mLock) {
|
||||
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
|
||||
@ -908,7 +891,7 @@ public final class Recorder implements VideoOutput {
|
||||
long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
|
||||
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
|
||||
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
|
||||
explicitlyStopTimeUs, error, errorCause));
|
||||
explicitlyStopTimeUs, ERROR_NONE, null));
|
||||
break;
|
||||
case ERROR:
|
||||
// In an error state, the recording will already be finalized. Treat as a
|
||||
@ -918,13 +901,9 @@ public final class Recorder implements VideoOutput {
|
||||
}
|
||||
|
||||
if (pendingRecordingToFinalize != null) {
|
||||
if (error == VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED) {
|
||||
Logger.e(TAG, "Recording was stopped due to recording being garbage collected "
|
||||
+ "before any valid data has been produced.");
|
||||
}
|
||||
finalizePendingRecording(pendingRecordingToFinalize, ERROR_NO_VALID_DATA,
|
||||
new RuntimeException("Recording was stopped before any data could be "
|
||||
+ "produced.", errorCause));
|
||||
+ "produced."));
|
||||
}
|
||||
}
|
||||
|
||||
@ -954,8 +933,7 @@ public final class Recorder implements VideoOutput {
|
||||
recordingToFinalize.getOutputOptions(),
|
||||
RecordingStats.of(/*duration=*/0L,
|
||||
/*bytes=*/0L,
|
||||
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause,
|
||||
AUDIO_AMPLITUDE_NONE)),
|
||||
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause)),
|
||||
OutputResults.of(Uri.EMPTY),
|
||||
error,
|
||||
cause));
|
||||
@ -986,15 +964,14 @@ public final class Recorder implements VideoOutput {
|
||||
// If we're inactive and have no active surface, we'll reset the encoder directly.
|
||||
// Otherwise, we'll wait for the active surface's surface request listener to
|
||||
// reset the encoder.
|
||||
requestReset(ERROR_SOURCE_INACTIVE, null, false);
|
||||
requestReset(ERROR_SOURCE_INACTIVE, null);
|
||||
} else {
|
||||
// The source becomes inactive, the incoming new surface request has to be cached
|
||||
// and be serviced after the Recorder is reset when receiving the previous
|
||||
// surface request complete callback.
|
||||
mNeedsResetBeforeNextStart = true;
|
||||
if (mInProgressRecording != null && !mInProgressRecording.isPersistent()) {
|
||||
// Stop the in progress recording with "source inactive" error if it's not a
|
||||
// persistent recording.
|
||||
mNeedsReset = true;
|
||||
if (mInProgressRecording != null) {
|
||||
// Stop any in progress recording with "source inactive" error
|
||||
onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE,
|
||||
null);
|
||||
}
|
||||
@ -1018,8 +995,7 @@ public final class Recorder implements VideoOutput {
|
||||
* the surface request complete callback first.
|
||||
*/
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause,
|
||||
boolean videoOnly) {
|
||||
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause) {
|
||||
boolean shouldReset = false;
|
||||
boolean shouldStop = false;
|
||||
synchronized (mLock) {
|
||||
@ -1041,22 +1017,14 @@ public final class Recorder implements VideoOutput {
|
||||
case PAUSED:
|
||||
// Fall-through
|
||||
case RECORDING:
|
||||
Preconditions.checkState(mInProgressRecording != null, "In-progress recording"
|
||||
+ " shouldn't be null when in state " + mState);
|
||||
if (mActiveRecordingRecord != mInProgressRecording) {
|
||||
throw new AssertionError("In-progress recording does not match the active"
|
||||
+ " recording. Unable to reset encoder.");
|
||||
}
|
||||
// If there's an active persistent recording, reset the Recorder directly.
|
||||
// Otherwise, stop the recording first then release the Recorder at
|
||||
// onRecordingFinalized().
|
||||
if (isPersistentRecordingInProgress()) {
|
||||
shouldReset = true;
|
||||
} else {
|
||||
// If there's an active recording, stop it first then release the resources
|
||||
// at onRecordingFinalized().
|
||||
shouldStop = true;
|
||||
setState(State.RESETTING);
|
||||
}
|
||||
break;
|
||||
// Fall-through
|
||||
case STOPPING:
|
||||
// Already stopping. Set state to RESETTING so resources will be released once
|
||||
// onRecordingFinalized() runs.
|
||||
@ -1071,17 +1039,14 @@ public final class Recorder implements VideoOutput {
|
||||
// These calls must not be posted to the executor to ensure they are executed inline on
|
||||
// the sequential executor and the state changes above are correctly handled.
|
||||
if (shouldReset) {
|
||||
if (videoOnly) {
|
||||
resetVideo();
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
} else if (shouldStop) {
|
||||
stopInternal(mInProgressRecording, Encoder.NO_TIMESTAMP, errorCode, errorCause);
|
||||
}
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
|
||||
private void configureInternal(@NonNull SurfaceRequest surfaceRequest,
|
||||
@NonNull Timebase videoSourceTimebase) {
|
||||
if (surfaceRequest.isServiced()) {
|
||||
@ -1089,19 +1054,16 @@ public final class Recorder implements VideoOutput {
|
||||
return;
|
||||
}
|
||||
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
|
||||
(transformationInfo) -> mSourceTransformationInfo = transformationInfo);
|
||||
(transformationInfo) -> mSurfaceTransformationInfo = transformationInfo);
|
||||
Size surfaceSize = surfaceRequest.getResolution();
|
||||
// Fetch and cache nearest encoder profiles, if one exists.
|
||||
DynamicRange dynamicRange = surfaceRequest.getDynamicRange();
|
||||
VideoCapabilities capabilities = getVideoCapabilities(
|
||||
surfaceRequest.getCamera().getCameraInfo());
|
||||
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize,
|
||||
dynamicRange);
|
||||
LegacyVideoCapabilities capabilities =
|
||||
LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo());
|
||||
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize);
|
||||
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
|
||||
+ " for surface size " + surfaceSize);
|
||||
if (highestSupportedQuality != Quality.NONE) {
|
||||
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality,
|
||||
dynamicRange);
|
||||
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality);
|
||||
if (mResolvedEncoderProfiles == null) {
|
||||
throw new AssertionError("Camera advertised available quality but did not "
|
||||
+ "produce EncoderProfiles for advertised quality.");
|
||||
@ -1114,14 +1076,9 @@ public final class Recorder implements VideoOutput {
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private void setupVideo(@NonNull SurfaceRequest request, @NonNull Timebase timebase) {
|
||||
safeToCloseVideoEncoder().addListener(() -> {
|
||||
if (request.isServiced()
|
||||
|| (mVideoEncoderSession.isConfiguredSurfaceRequest(request)
|
||||
&& !isPersistentRecordingInProgress())) {
|
||||
// Ignore the surface request if it's already serviced. Or the video encoder
|
||||
// session is already configured, unless there's a persistent recording is running.
|
||||
if (request.isServiced() || mVideoEncoderSession.isConfiguredSurfaceRequest(request)) {
|
||||
Logger.w(TAG, "Ignore the SurfaceRequest " + request + " isServiced: "
|
||||
+ request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession
|
||||
+ " has been configured with a persistent in-progress recording.");
|
||||
+ request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession);
|
||||
return;
|
||||
}
|
||||
VideoEncoderSession videoEncoderSession =
|
||||
@ -1153,12 +1110,6 @@ public final class Recorder implements VideoOutput {
|
||||
}, mSequentialExecutor);
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
boolean isPersistentRecordingInProgress() {
|
||||
return mInProgressRecording != null && mInProgressRecording.isPersistent();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private ListenableFuture<Void> safeToCloseVideoEncoder() {
|
||||
@ -1194,9 +1145,7 @@ public final class Recorder implements VideoOutput {
|
||||
|
||||
mVideoEncoderSessionToRelease = videoEncoderSession;
|
||||
setLatestSurface(null);
|
||||
// Only reset video if the in-progress recording is persistent.
|
||||
requestReset(ERROR_SOURCE_INACTIVE, null,
|
||||
isPersistentRecordingInProgress());
|
||||
requestReset(ERROR_SOURCE_INACTIVE, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -1211,14 +1160,17 @@ public final class Recorder implements VideoOutput {
|
||||
void onConfigured() {
|
||||
RecordingRecord recordingToStart = null;
|
||||
RecordingRecord pendingRecordingToFinalize = null;
|
||||
boolean continuePersistentRecording = false;
|
||||
@VideoRecordError int error = ERROR_NONE;
|
||||
Throwable errorCause = null;
|
||||
boolean recordingPaused = false;
|
||||
boolean startRecordingPaused = false;
|
||||
synchronized (mLock) {
|
||||
switch (mState) {
|
||||
case IDLING:
|
||||
// Fall-through
|
||||
case RECORDING:
|
||||
// Fall-through
|
||||
case PAUSED:
|
||||
// Fall-through
|
||||
case RESETTING:
|
||||
throw new AssertionError(
|
||||
"Incorrectly invoke onConfigured() in state " + mState);
|
||||
@ -1228,15 +1180,6 @@ public final class Recorder implements VideoOutput {
|
||||
+ "STOPPING state when it's not waiting for a new surface.");
|
||||
}
|
||||
break;
|
||||
case PAUSED:
|
||||
recordingPaused = true;
|
||||
// Fall-through
|
||||
case RECORDING:
|
||||
Preconditions.checkState(isPersistentRecordingInProgress(),
|
||||
"Unexpectedly invoke onConfigured() when there's a non-persistent "
|
||||
+ "in-progress recording");
|
||||
continuePersistentRecording = true;
|
||||
break;
|
||||
case CONFIGURING:
|
||||
setState(State.IDLING);
|
||||
break;
|
||||
@ -1245,7 +1188,7 @@ public final class Recorder implements VideoOutput {
|
||||
"onConfigured() was invoked when the Recorder had encountered error");
|
||||
break;
|
||||
case PENDING_PAUSED:
|
||||
recordingPaused = true;
|
||||
startRecordingPaused = true;
|
||||
// Fall through
|
||||
case PENDING_RECORDING:
|
||||
if (mActiveRecordingRecord != null) {
|
||||
@ -1266,21 +1209,9 @@ public final class Recorder implements VideoOutput {
|
||||
}
|
||||
}
|
||||
|
||||
if (continuePersistentRecording) {
|
||||
updateEncoderCallbacks(mInProgressRecording, true);
|
||||
mVideoEncoder.start();
|
||||
if (mShouldSendResumeEvent) {
|
||||
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume(
|
||||
mInProgressRecording.getOutputOptions(),
|
||||
getInProgressRecordingStats()));
|
||||
mShouldSendResumeEvent = false;
|
||||
}
|
||||
if (recordingPaused) {
|
||||
mVideoEncoder.pause();
|
||||
}
|
||||
} else if (recordingToStart != null) {
|
||||
if (recordingToStart != null) {
|
||||
// Start new active recording inline on sequential executor (but unlocked).
|
||||
startRecording(recordingToStart, recordingPaused);
|
||||
startRecording(recordingToStart, startRecordingPaused);
|
||||
} else if (pendingRecordingToFinalize != null) {
|
||||
finalizePendingRecording(pendingRecordingToFinalize, error, errorCause);
|
||||
}
|
||||
@ -1321,7 +1252,7 @@ public final class Recorder implements VideoOutput {
|
||||
throws AudioSourceAccessException, InvalidConfigException {
|
||||
MediaSpec mediaSpec = getObservableData(mMediaSpec);
|
||||
// Resolve the audio mime info
|
||||
AudioMimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
|
||||
MimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
|
||||
Timebase audioSourceTimebase = Timebase.UPTIME;
|
||||
|
||||
// Select and create the audio source
|
||||
@ -1472,10 +1403,8 @@ public final class Recorder implements VideoOutput {
|
||||
return;
|
||||
}
|
||||
|
||||
SurfaceRequest.TransformationInfo transformationInfo = mSourceTransformationInfo;
|
||||
if (transformationInfo != null) {
|
||||
setInProgressTransformationInfo(transformationInfo);
|
||||
mediaMuxer.setOrientationHint(transformationInfo.getRotationDegrees());
|
||||
if (mSurfaceTransformationInfo != null) {
|
||||
mediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees());
|
||||
}
|
||||
Location location = recordingToStart.getOutputOptions().getLocation();
|
||||
if (location != null) {
|
||||
@ -1578,9 +1507,7 @@ public final class Recorder implements VideoOutput {
|
||||
"The Recorder doesn't support recording with audio");
|
||||
}
|
||||
try {
|
||||
if (!mInProgressRecording.isPersistent() || mAudioEncoder == null) {
|
||||
setupAudio(recordingToStart);
|
||||
}
|
||||
setAudioState(AudioState.ENABLED);
|
||||
} catch (AudioSourceAccessException | InvalidConfigException e) {
|
||||
Logger.e(TAG, "Unable to create audio resource with error: ", e);
|
||||
@ -1597,7 +1524,7 @@ public final class Recorder implements VideoOutput {
|
||||
break;
|
||||
}
|
||||
|
||||
updateEncoderCallbacks(recordingToStart, false);
|
||||
initEncoderAndAudioSourceCallbacks(recordingToStart);
|
||||
if (isAudioEnabled()) {
|
||||
mAudioSource.start(recordingToStart.isMuted());
|
||||
mAudioEncoder.start();
|
||||
@ -1610,17 +1537,7 @@ public final class Recorder implements VideoOutput {
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private void updateEncoderCallbacks(@NonNull RecordingRecord recordingToStart,
|
||||
boolean videoOnly) {
|
||||
// If there are uncompleted futures, cancel them first.
|
||||
if (!mEncodingFutures.isEmpty()) {
|
||||
ListenableFuture<List<Void>> listFuture = Futures.allAsList(mEncodingFutures);
|
||||
if (!listFuture.isDone()) {
|
||||
listFuture.cancel(true);
|
||||
}
|
||||
mEncodingFutures.clear();
|
||||
}
|
||||
|
||||
private void initEncoderAndAudioSourceCallbacks(@NonNull RecordingRecord recordingToStart) {
|
||||
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
||||
completer -> {
|
||||
mVideoEncoder.setEncoderCallback(new EncoderCallback() {
|
||||
@ -1716,7 +1633,7 @@ public final class Recorder implements VideoOutput {
|
||||
return "videoEncodingFuture";
|
||||
}));
|
||||
|
||||
if (isAudioEnabled() && !videoOnly) {
|
||||
if (isAudioEnabled()) {
|
||||
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
||||
completer -> {
|
||||
Consumer<Throwable> audioErrorConsumer = throwable -> {
|
||||
@ -1756,11 +1673,6 @@ public final class Recorder implements VideoOutput {
|
||||
audioErrorConsumer.accept(throwable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAmplitudeValue(double maxAmplitude) {
|
||||
mAudioAmplitude = maxAmplitude;
|
||||
}
|
||||
});
|
||||
|
||||
mAudioEncoder.setEncoderCallback(new EncoderCallback() {
|
||||
@ -1847,16 +1759,12 @@ public final class Recorder implements VideoOutput {
|
||||
|
||||
@Override
|
||||
public void onFailure(@NonNull Throwable t) {
|
||||
Preconditions.checkState(mInProgressRecording != null,
|
||||
"In-progress recording shouldn't be null");
|
||||
// If a persistent recording requires reconfiguring the video encoder,
|
||||
// the previous encoder future has to be canceled without finalizing the
|
||||
// in-progress recording.
|
||||
if (!mInProgressRecording.isPersistent()) {
|
||||
Logger.d(TAG, "Encodings end with error: " + t);
|
||||
finalizeInProgressRecording(mMediaMuxer == null ? ERROR_NO_VALID_DATA
|
||||
: ERROR_ENCODING_FAILED, t);
|
||||
}
|
||||
// If the media muxer hasn't been set up, assume the encoding fails
|
||||
// because of no valid data has been produced.
|
||||
finalizeInProgressRecording(
|
||||
mMediaMuxer == null ? ERROR_NO_VALID_DATA : ERROR_ENCODING_FAILED,
|
||||
t);
|
||||
}
|
||||
},
|
||||
// Can use direct executor since completers are always completed on sequential
|
||||
@ -1997,20 +1905,11 @@ public final class Recorder implements VideoOutput {
|
||||
if (isAudioEnabled()) {
|
||||
mAudioEncoder.start();
|
||||
}
|
||||
// If a persistent recording is resumed immediately after the VideoCapture is rebound
|
||||
// to a camera, it's possible that the encoder hasn't been created yet. Then the
|
||||
// encoder will be started once it's initialized. So only start the encoder when it's
|
||||
// not null.
|
||||
if (mVideoEncoder != null) {
|
||||
mVideoEncoder.start();
|
||||
|
||||
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume(
|
||||
mInProgressRecording.getOutputOptions(),
|
||||
getInProgressRecordingStats()));
|
||||
} else {
|
||||
// Instead sending here, send the Resume event once the encoder is initialized
|
||||
// and started.
|
||||
mShouldSendResumeEvent = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2104,12 +2003,13 @@ public final class Recorder implements VideoOutput {
|
||||
mAudioEncoder = null;
|
||||
mAudioOutputConfig = null;
|
||||
}
|
||||
tryReleaseVideoEncoder();
|
||||
if (mAudioSource != null) {
|
||||
releaseCurrentAudioSource();
|
||||
}
|
||||
|
||||
setAudioState(AudioState.INITIALIZING);
|
||||
resetVideo();
|
||||
onReset();
|
||||
}
|
||||
|
||||
@SuppressWarnings("FutureReturnValueIgnored")
|
||||
@ -2131,8 +2031,7 @@ public final class Recorder implements VideoOutput {
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private void onResetVideo() {
|
||||
boolean shouldConfigure = true;
|
||||
private void onReset() {
|
||||
synchronized (mLock) {
|
||||
switch (mState) {
|
||||
case PENDING_PAUSED:
|
||||
@ -2145,10 +2044,6 @@ public final class Recorder implements VideoOutput {
|
||||
case PAUSED:
|
||||
// Fall-through
|
||||
case RECORDING:
|
||||
if (isPersistentRecordingInProgress()) {
|
||||
shouldConfigure = false;
|
||||
break;
|
||||
}
|
||||
// Fall-through
|
||||
case IDLING:
|
||||
// Fall-through
|
||||
@ -2163,24 +2058,14 @@ public final class Recorder implements VideoOutput {
|
||||
}
|
||||
}
|
||||
|
||||
mNeedsResetBeforeNextStart = false;
|
||||
mNeedsReset = false;
|
||||
|
||||
// If the latest surface request hasn't been serviced, use it to re-configure the Recorder.
|
||||
if (shouldConfigure && mLatestSurfaceRequest != null
|
||||
&& !mLatestSurfaceRequest.isServiced()) {
|
||||
if (mLatestSurfaceRequest != null && !mLatestSurfaceRequest.isServiced()) {
|
||||
configureInternal(mLatestSurfaceRequest, mVideoSourceTimebase);
|
||||
}
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private void resetVideo() {
|
||||
if (mVideoEncoder != null) {
|
||||
Logger.d(TAG, "Releasing video encoder.");
|
||||
tryReleaseVideoEncoder();
|
||||
}
|
||||
onResetVideo();
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
private int internalAudioStateToAudioStatsState(@NonNull AudioState audioState) {
|
||||
switch (audioState) {
|
||||
@ -2283,9 +2168,7 @@ public final class Recorder implements VideoOutput {
|
||||
mRecordingStopError = ERROR_UNKNOWN;
|
||||
mRecordingStopErrorCause = null;
|
||||
mAudioErrorCause = null;
|
||||
mAudioAmplitude = AUDIO_AMPLITUDE_NONE;
|
||||
clearPendingAudioRingBuffer();
|
||||
setInProgressTransformationInfo(null);
|
||||
|
||||
switch (mAudioState) {
|
||||
case IDLING:
|
||||
@ -2468,7 +2351,7 @@ public final class Recorder implements VideoOutput {
|
||||
startRecordingPaused = true;
|
||||
// Fall-through
|
||||
case PENDING_RECORDING:
|
||||
if (mActiveRecordingRecord != null || mNeedsResetBeforeNextStart) {
|
||||
if (mActiveRecordingRecord != null || mNeedsReset) {
|
||||
// Active recording is still finalizing or the Recorder is expected to be
|
||||
// reset. Pending recording will be serviced in onRecordingFinalized() or
|
||||
// in onReset().
|
||||
@ -2583,8 +2466,7 @@ public final class Recorder implements VideoOutput {
|
||||
@NonNull
|
||||
RecordingStats getInProgressRecordingStats() {
|
||||
return RecordingStats.of(mRecordingDurationNs, mRecordingBytes,
|
||||
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause,
|
||||
mAudioAmplitude));
|
||||
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause));
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@ -2638,7 +2520,7 @@ public final class Recorder implements VideoOutput {
|
||||
if (streamState == null) {
|
||||
streamState = internalStateToStreamState(mState);
|
||||
}
|
||||
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState, mInProgressTransformationInfo));
|
||||
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState));
|
||||
}
|
||||
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
@ -2660,20 +2542,7 @@ public final class Recorder implements VideoOutput {
|
||||
}
|
||||
Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + streamId);
|
||||
mStreamId = streamId;
|
||||
mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState),
|
||||
mInProgressTransformationInfo));
|
||||
}
|
||||
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@ExecutedBy("mSequentialExecutor")
|
||||
void setInProgressTransformationInfo(
|
||||
@Nullable SurfaceRequest.TransformationInfo transformationInfo) {
|
||||
Logger.d(TAG, "Update stream transformation info: " + transformationInfo);
|
||||
mInProgressTransformationInfo = transformationInfo;
|
||||
synchronized (mLock) {
|
||||
mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(mState),
|
||||
transformationInfo));
|
||||
}
|
||||
mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState)));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -2696,8 +2565,8 @@ public final class Recorder implements VideoOutput {
|
||||
|
||||
if (mNonPendingState != state) {
|
||||
mNonPendingState = state;
|
||||
mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(state),
|
||||
mInProgressTransformationInfo));
|
||||
mStreamInfo.setState(
|
||||
StreamInfo.of(mStreamId, internalStateToStreamState(state)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2745,21 +2614,6 @@ public final class Recorder implements VideoOutput {
|
||||
return defaultMuxerFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link VideoCapabilities} of Recorder with respect to input camera information.
|
||||
*
|
||||
* <p>{@link VideoCapabilities} provides methods to query supported dynamic ranges and
|
||||
* qualities. This information can be used for things like checking if HDR is supported for
|
||||
* configuring VideoCapture to record HDR video.
|
||||
*
|
||||
* @param cameraInfo info about the camera.
|
||||
* @return VideoCapabilities with respect to the input camera info.
|
||||
*/
|
||||
@NonNull
|
||||
public static VideoCapabilities getVideoCapabilities(@NonNull CameraInfo cameraInfo) {
|
||||
return RecorderVideoCapabilities.from(cameraInfo);
|
||||
}
|
||||
|
||||
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
|
||||
@AutoValue
|
||||
abstract static class RecordingRecord implements AutoCloseable {
|
||||
@ -2788,7 +2642,6 @@ public final class Recorder implements VideoOutput {
|
||||
pendingRecording.getListenerExecutor(),
|
||||
pendingRecording.getEventListener(),
|
||||
pendingRecording.isAudioEnabled(),
|
||||
pendingRecording.isPersistent(),
|
||||
recordingId
|
||||
);
|
||||
}
|
||||
@ -2804,8 +2657,6 @@ public final class Recorder implements VideoOutput {
|
||||
|
||||
abstract boolean hasAudioEnabled();
|
||||
|
||||
abstract boolean isPersistent();
|
||||
|
||||
abstract long getRecordingId();
|
||||
|
||||
/**
|
||||
@ -2869,13 +2720,8 @@ public final class Recorder implements VideoOutput {
|
||||
// Toggle on pending status for the video file.
|
||||
contentValues.put(MediaStore.Video.Media.IS_PENDING, PENDING);
|
||||
}
|
||||
try {
|
||||
outputUri = mediaStoreOutputOptions.getContentResolver().insert(
|
||||
mediaStoreOutputOptions.getCollectionUri(), contentValues);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Unable to create MediaStore entry by " + e,
|
||||
e);
|
||||
}
|
||||
if (outputUri == null) {
|
||||
throw new IOException("Unable to create MediaStore entry.");
|
||||
}
|
||||
@ -3097,12 +2943,7 @@ public final class Recorder implements VideoOutput {
|
||||
throw new AssertionError("One-time media muxer creation has already occurred for"
|
||||
+ " recording " + this);
|
||||
}
|
||||
|
||||
try {
|
||||
return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Failed to create MediaMuxer by " + e, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -19,7 +19,6 @@ package androidx.camera.video;
|
||||
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.RestrictTo;
|
||||
import androidx.camera.core.impl.utils.CloseGuardHelper;
|
||||
@ -54,15 +53,13 @@ public final class Recording implements AutoCloseable {
|
||||
private final Recorder mRecorder;
|
||||
private final long mRecordingId;
|
||||
private final OutputOptions mOutputOptions;
|
||||
private final boolean mIsPersistent;
|
||||
private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
|
||||
|
||||
Recording(@NonNull Recorder recorder, long recordingId, @NonNull OutputOptions options,
|
||||
boolean isPersistent, boolean finalizedOnCreation) {
|
||||
boolean finalizedOnCreation) {
|
||||
mRecorder = recorder;
|
||||
mRecordingId = recordingId;
|
||||
mOutputOptions = options;
|
||||
mIsPersistent = isPersistent;
|
||||
|
||||
if (finalizedOnCreation) {
|
||||
mIsClosed.set(true);
|
||||
@ -83,7 +80,6 @@ public final class Recording implements AutoCloseable {
|
||||
return new Recording(pendingRecording.getRecorder(),
|
||||
recordingId,
|
||||
pendingRecording.getOutputOptions(),
|
||||
pendingRecording.isPersistent(),
|
||||
/*finalizedOnCreation=*/false);
|
||||
}
|
||||
|
||||
@ -104,7 +100,6 @@ public final class Recording implements AutoCloseable {
|
||||
return new Recording(pendingRecording.getRecorder(),
|
||||
recordingId,
|
||||
pendingRecording.getOutputOptions(),
|
||||
pendingRecording.isPersistent(),
|
||||
/*finalizedOnCreation=*/true);
|
||||
}
|
||||
|
||||
@ -113,20 +108,6 @@ public final class Recording implements AutoCloseable {
|
||||
return mOutputOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this recording is a persistent recording.
|
||||
*
|
||||
* <p>A persistent recording will only be stopped by explicitly calling of
|
||||
* {@link Recording#stop()} and will ignore the lifecycle events or source state changes.
|
||||
* Users are responsible of stopping a persistent recording.
|
||||
*
|
||||
* @return {@code true} if the recording is a persistent recording, otherwise {@code false}.
|
||||
*/
|
||||
@ExperimentalPersistentRecording
|
||||
public boolean isPersistent() {
|
||||
return mIsPersistent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the current recording if active.
|
||||
*
|
||||
@ -212,7 +193,11 @@ public final class Recording implements AutoCloseable {
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null);
|
||||
mCloseGuard.close();
|
||||
if (mIsClosed.getAndSet(true)) {
|
||||
return;
|
||||
}
|
||||
mRecorder.stop(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -220,8 +205,7 @@ public final class Recording implements AutoCloseable {
|
||||
protected void finalize() throws Throwable {
|
||||
try {
|
||||
mCloseGuard.warnIfOpen();
|
||||
stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED,
|
||||
new RuntimeException("Recording stopped due to being garbage collected."));
|
||||
stop();
|
||||
} finally {
|
||||
super.finalize();
|
||||
}
|
||||
@ -247,14 +231,5 @@ public final class Recording implements AutoCloseable {
|
||||
public boolean isClosed() {
|
||||
return mIsClosed.get();
|
||||
}
|
||||
|
||||
private void stopWithError(@VideoRecordEvent.Finalize.VideoRecordError int error,
|
||||
@Nullable Throwable errorCause) {
|
||||
mCloseGuard.close();
|
||||
if (mIsClosed.getAndSet(true)) {
|
||||
return;
|
||||
}
|
||||
mRecorder.stop(this, error, errorCause);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,5 @@
|
||||
#!/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
|
||||
|
@ -4,7 +4,6 @@ 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
|
||||
@ -13,7 +12,7 @@ open class BaseActivity: AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
sharedPrefs = (application as VolumeManagerApp).sharedPreferences
|
||||
theme = Theme.fromSharedPrefs(sharedPrefs)
|
||||
if (applyCustomTheme) {
|
||||
setTheme(theme.toResourceId())
|
||||
|
@ -7,10 +7,7 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.util.Size
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.Surface
|
||||
import android.view.View
|
||||
import android.view.*
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.view.animation.RotateAnimation
|
||||
@ -21,7 +18,6 @@ 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
|
||||
@ -45,8 +41,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
|
||||
@ -55,9 +51,7 @@ import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.Random
|
||||
import java.util.*
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
@ -118,10 +112,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
binding = ActivityCameraBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.hide()
|
||||
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
|
||||
intent.getIntExtra("volumeId", -1)
|
||||
)!!
|
||||
finishOnClose(encryptedVolume)
|
||||
encryptedVolume = IntentUtils.getParcelableExtra(intent, "volume")!!
|
||||
outputDirectory = intent.getStringExtra("path")!!
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@ -423,7 +414,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
refreshVideoCapture()
|
||||
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture)
|
||||
if (qualities == null) {
|
||||
qualities = SucklessRecorder.getVideoCapabilities(camera!!.cameraInfo).getSupportedQualities(DynamicRange.UNSPECIFIED)
|
||||
qualities = QualitySelector.getSupportedQualities(camera!!.cameraInfo)
|
||||
}
|
||||
videoCapture
|
||||
} else {
|
||||
@ -585,8 +576,12 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (encryptedVolume.isClosed()) {
|
||||
finish()
|
||||
} else {
|
||||
sensorOrientationListener.addListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOrientationChange(newOrientation: Int) {
|
||||
val realOrientation = when (newOrientation) {
|
||||
|
@ -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.UIUtils
|
||||
import sushi.hardcore.droidfs.util.WidgetUtil
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.util.*
|
||||
|
||||
@ -89,8 +89,8 @@ class ChangePasswordActivity: BaseActivity() {
|
||||
}
|
||||
|
||||
private fun changeVolumePassword() {
|
||||
val newPassword = UIUtils.encodeEditTextContent(binding.editNewPassword)
|
||||
val newPasswordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
|
||||
val newPassword = WidgetUtil.encodeEditTextContent(binding.editNewPassword)
|
||||
val newPasswordConfirm = WidgetUtil.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) {
|
||||
UIUtils.encodeEditTextContent(binding.editCurrentPassword)
|
||||
WidgetUtil.encodeEditTextContent(binding.editCurrentPassword)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@ -160,7 +160,7 @@ class ChangePasswordActivity: BaseActivity() {
|
||||
)
|
||||
}
|
||||
if (success) {
|
||||
if (volumeDatabase.isHashSaved(volume)) {
|
||||
if (volumeDatabase.isHashSaved(volume.name)) {
|
||||
volumeDatabase.removeHash(volume)
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Dummy background service listening for application task removal in order to
|
||||
* close all volumes still open on quit.
|
||||
*
|
||||
* Should only be running when usfBackground is enabled AND usfKeepOpen is disabled.
|
||||
*/
|
||||
class ClosingService : Service() {
|
||||
override fun onBind(intent: Intent) = null
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
(application as VolumeManagerApp).volumeManager.closeAll()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
@ -7,10 +7,7 @@ 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
|
||||
@ -25,23 +22,6 @@ 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()
|
||||
@ -53,11 +33,6 @@ 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(
|
||||
@ -88,11 +63,9 @@ class EncryptedFileProvider(context: Context) {
|
||||
}
|
||||
|
||||
override fun free() {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Wiper.wipe(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExportedMemFile private constructor(path: String, private val file: MemFile) :
|
||||
ExportedFile(path) {
|
||||
@ -145,18 +118,16 @@ class EncryptedFileProvider(context: Context) {
|
||||
path: String,
|
||||
size: Long,
|
||||
): ExportedFile? {
|
||||
val diskFile by lazy { ExportedDiskFile.create(path, tmpFilesDir, handler) }
|
||||
val memFile by lazy { ExportedMemFile.create(path, size) }
|
||||
return when (exportMethod) {
|
||||
ExportMethod.MEMORY -> memFile
|
||||
ExportMethod.DISK -> diskFile
|
||||
ExportMethod.AUTO -> {
|
||||
if (isMemFileSupported && size < memoryInfo.availMem * 0.8) {
|
||||
memFile
|
||||
return if (size > memoryInfo.availMem * 0.8) {
|
||||
ExportedDiskFile.create(
|
||||
path,
|
||||
tmpFilesDir,
|
||||
handler,
|
||||
)
|
||||
} else if (isMemFileSupported) {
|
||||
ExportedMemFile.create(path, size) as ExportedFile
|
||||
} else {
|
||||
diskFile
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,10 +135,8 @@ class EncryptedFileProvider(context: Context) {
|
||||
exportedFile: ExportedFile,
|
||||
encryptedVolume: EncryptedVolume,
|
||||
): Boolean {
|
||||
val pfd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false)
|
||||
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(pfd.fileDescriptor)).also {
|
||||
pfd.close()
|
||||
}
|
||||
val fd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false).fileDescriptor
|
||||
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(fd))
|
||||
}
|
||||
|
||||
enum class Error {
|
||||
|
@ -4,9 +4,9 @@ import java.io.File
|
||||
|
||||
object FileTypes {
|
||||
private val FILE_EXTENSIONS = mapOf(
|
||||
Pair("image", listOf("png", "jpg", "jpeg", "gif", "avif", "webp", "bmp", "heic")),
|
||||
Pair("video", listOf("mp4", "webm", "mkv", "mov", "m4v")),
|
||||
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac", "opus")),
|
||||
Pair("image", listOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "heic")),
|
||||
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
|
||||
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac")),
|
||||
Pair("pdf", listOf("pdf")),
|
||||
Pair("text", listOf(
|
||||
"asc",
|
||||
|
@ -1,120 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.IntentCompat
|
||||
|
||||
class KeepAliveService: Service() {
|
||||
internal class NotificationDetails(
|
||||
val channel: String,
|
||||
val title: String,
|
||||
val text: String,
|
||||
val action: NotificationAction,
|
||||
) : Parcelable {
|
||||
internal class NotificationAction(
|
||||
val icon: Int,
|
||||
val title: String,
|
||||
val action: String,
|
||||
)
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
NotificationAction(
|
||||
parcel.readInt(),
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
)
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
with (parcel) {
|
||||
writeString(channel)
|
||||
writeString(title)
|
||||
writeString(text)
|
||||
writeInt(action.icon)
|
||||
writeString(action.title)
|
||||
writeString(action.action)
|
||||
}
|
||||
}
|
||||
|
||||
override fun describeContents() = 0
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<NotificationDetails> {
|
||||
override fun createFromParcel(parcel: Parcel) = NotificationDetails(parcel)
|
||||
override fun newArray(size: Int) = arrayOfNulls<NotificationDetails>(size)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "start"
|
||||
|
||||
/**
|
||||
* If [startForeground] is called before notification permission is granted,
|
||||
* the notification won't appear.
|
||||
*
|
||||
* This action can be used once the permission is granted, to make the service
|
||||
* call [startForeground] again in order to properly show the notification.
|
||||
*/
|
||||
const val ACTION_FOREGROUND = "foreground"
|
||||
const val NOTIFICATION_CHANNEL_ID = "KeepAlive"
|
||||
}
|
||||
|
||||
private val notificationManager by lazy {
|
||||
NotificationManagerCompat.from(this)
|
||||
}
|
||||
private var notification: Notification? = null
|
||||
|
||||
override fun onBind(intent: Intent?) = null
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
if (intent.action == ACTION_START) {
|
||||
val notificationDetails = IntentCompat.getParcelableExtra(intent, "notification", NotificationDetails::class.java)!!
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
notificationDetails.channel,
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
)
|
||||
}
|
||||
notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(notificationDetails.title)
|
||||
.setContentText(notificationDetails.text)
|
||||
.addAction(NotificationCompat.Action(
|
||||
notificationDetails.action.icon,
|
||||
notificationDetails.action.title,
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
0,
|
||||
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||
action = notificationDetails.action.action
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
))
|
||||
.build()
|
||||
}
|
||||
ServiceCompat.startForeground(this, startId, notification!!, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC // is there a better use case flag?
|
||||
} else {
|
||||
0
|
||||
})
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.databinding.ActivityLogcatBinding
|
||||
import java.io.BufferedReader
|
||||
import java.io.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,8 +1,12 @@
|
||||
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
|
||||
@ -16,7 +20,6 @@ import kotlinx.coroutines.launch
|
||||
import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
|
||||
import sushi.hardcore.droidfs.adapters.VolumeAdapter
|
||||
import sushi.hardcore.droidfs.add_volume.AddVolumeActivity
|
||||
import sushi.hardcore.droidfs.content_providers.VolumeProvider
|
||||
import sushi.hardcore.droidfs.databinding.ActivityMainBinding
|
||||
import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerRouter
|
||||
@ -24,7 +27,6 @@ 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
|
||||
@ -127,8 +129,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
}
|
||||
}
|
||||
}
|
||||
FileOperationService.bind(this) {
|
||||
fileOperationService = it
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -179,13 +187,15 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
}
|
||||
|
||||
private fun unselect(position: Int) {
|
||||
volumeAdapter.unselect(position)
|
||||
volumeAdapter.selectedItems.remove(position)
|
||||
volumeAdapter.onVolumeChanged(position)
|
||||
onSelectionChanged(0) // unselect() is always called when only one element is selected
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
private fun removeVolume(volume: VolumeData) {
|
||||
volumeManager.getVolumeId(volume)?.let { volumeManager.closeVolume(it) }
|
||||
volumeDatabase.removeVolume(volume)
|
||||
volumeDatabase.removeVolume(volume.name)
|
||||
}
|
||||
|
||||
private fun removeVolumes(volumes: List<VolumeData>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
|
||||
@ -278,7 +288,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
R.id.delete_password_hash -> {
|
||||
for (i in volumeAdapter.selectedItems) {
|
||||
if (volumeDatabase.removeHash(volumeAdapter.volumes[i]))
|
||||
volumeAdapter.onVolumeDataChanged(i)
|
||||
volumeAdapter.onVolumeChanged(i)
|
||||
}
|
||||
unselectAll(false)
|
||||
true
|
||||
@ -299,7 +309,6 @@ 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))
|
||||
@ -315,14 +324,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
DocumentFile.fromFile(File(volume.name)),
|
||||
DocumentFile.fromFile(hiddenVolumeFile.parentFile!!),
|
||||
) {
|
||||
VolumeData(
|
||||
VolumeData.newUuid(),
|
||||
volume.shortName,
|
||||
true,
|
||||
volume.type,
|
||||
volume.encryptedHash,
|
||||
volume.iv
|
||||
)
|
||||
VolumeData(volume.shortName, true, volume.type, volume.encryptedHash, volume.iv)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -344,11 +346,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.main_activity, menu)
|
||||
val settingsVisible = !explorerRouter.pickMode && !explorerRouter.dropMode
|
||||
menu.findItem(R.id.settings).isVisible = settingsVisible
|
||||
if (settingsVisible) {
|
||||
UIUtils.getMenuIconNeutralTint(this, menu).applyTo(R.id.settings, R.drawable.icon_settings)
|
||||
}
|
||||
menu.findItem(R.id.settings).isVisible = !explorerRouter.pickMode && !explorerRouter.dropMode
|
||||
val isSelecting = volumeAdapter.selectedItems.isNotEmpty()
|
||||
menu.findItem(R.id.select_all).isVisible = isSelecting
|
||||
menu.findItem(R.id.lock).isVisible = isSelecting && volumeAdapter.selectedItems.any {
|
||||
@ -376,7 +374,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
)
|
||||
}
|
||||
}
|
||||
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable && !volumeManager.isOpen(volumeAdapter.volumes[volumeAdapter.selectedItems.first()])
|
||||
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode)
|
||||
return true
|
||||
}
|
||||
@ -400,7 +398,6 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this)
|
||||
if (path == null) null
|
||||
else VolumeData(
|
||||
VolumeData.newUuid(),
|
||||
PathUtils.pathJoin(path, name),
|
||||
false,
|
||||
volume.type,
|
||||
@ -423,6 +420,9 @@ 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 ->
|
||||
@ -444,7 +444,6 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
.show()
|
||||
}
|
||||
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
|
||||
TaskResult.State.CANCELLED -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -467,9 +466,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
||||
DocumentFile.fromFile(srcPath).renameTo(newName)
|
||||
}
|
||||
if (success) {
|
||||
volumeDatabase.renameVolume(volume, newDBName)
|
||||
VolumeProvider.notifyRootsChanged(this)
|
||||
volumeAdapter.onVolumeDataChanged(position)
|
||||
volumeDatabase.renameVolume(volume.name, newDBName)
|
||||
unselect(position)
|
||||
if (volume.name == volumeOpener.defaultVolumeName) {
|
||||
with (sharedPrefs.edit()) {
|
||||
|
@ -1,23 +0,0 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import sushi.hardcore.droidfs.file_operations.FileOperationService
|
||||
|
||||
class NotificationBroadcastReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
FileOperationService.ACTION_CANCEL -> {
|
||||
intent.getBundleExtra("bundle")?.let { bundle ->
|
||||
// TODO: use peekService instead?
|
||||
val binder = (bundle.getBinder("binder") as FileOperationService.LocalBinder?)
|
||||
binder?.getService()?.cancelOperation(bundle.getInt("taskId"))
|
||||
}
|
||||
}
|
||||
VolumeManagerApp.ACTION_CLOSE_ALL_VOLUMES -> {
|
||||
(context.applicationContext as VolumeManagerApp).volumeManager.closeAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
@ -8,23 +7,21 @@ 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)
|
||||
@ -93,15 +90,9 @@ class SettingsActivity : BaseActivity() {
|
||||
|
||||
private fun refreshTheme() {
|
||||
with(requireActivity()) {
|
||||
startActivity(
|
||||
Intent(this, SettingsActivity::class.java),
|
||||
ActivityOptions.makeCustomAnimation(
|
||||
this,
|
||||
android.R.anim.fade_in,
|
||||
android.R.anim.fade_out
|
||||
).toBundle()
|
||||
)
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
finish()
|
||||
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,10 +120,6 @@ class SettingsActivity : BaseActivity() {
|
||||
false
|
||||
}
|
||||
}
|
||||
findPreference<Preference>("logcat")?.setOnPreferenceClickListener { _ ->
|
||||
startActivity(Intent(requireContext(), LogcatActivity::class.java))
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,66 +158,45 @@ class SettingsActivity : BaseActivity() {
|
||||
true
|
||||
}
|
||||
}
|
||||
val switchBackground = findPreference<SwitchPreference>("usf_background")!!
|
||||
val switchKeepOpen = findPreference<SwitchPreference>("usf_keep_open")!!
|
||||
val switchExternalOpen = findPreference<SwitchPreference>("usf_open")!!
|
||||
val switchExpose = findPreference<SwitchPreference>("usf_expose")!!
|
||||
val switchSafWrite = findPreference<SwitchPreference>("usf_saf_write")!!
|
||||
|
||||
fun onUsfBackgroundChanged(usfBackground: Boolean) {
|
||||
fun updateSwitchPreference(switch: SwitchPreference) = with (switch) {
|
||||
isChecked = isChecked && usfBackground
|
||||
isEnabled = usfBackground
|
||||
onPreferenceChangeListener?.onPreferenceChange(switch, isChecked)
|
||||
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)
|
||||
}
|
||||
updateSwitchPreference(switchKeepOpen)
|
||||
updateSwitchPreference(switchExpose)
|
||||
}
|
||||
onUsfBackgroundChanged(switchBackground.isChecked)
|
||||
|
||||
fun updateSafWrite(usfOpen: Boolean? = null, usfExpose: Boolean? = null) {
|
||||
switchSafWrite.isEnabled = usfOpen ?: switchExternalOpen.isChecked || usfExpose ?: switchExpose.isChecked
|
||||
}
|
||||
updateSafWrite()
|
||||
|
||||
switchBackground.setOnPreferenceChangeListener { _, checked ->
|
||||
onUsfBackgroundChanged(checked as Boolean)
|
||||
updateView()
|
||||
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
|
||||
updateView(usfKeepOpen = checked as Boolean)
|
||||
true
|
||||
}
|
||||
switchExternalOpen.setOnPreferenceChangeListener { _, checked ->
|
||||
updateSafWrite(usfOpen = checked as Boolean)
|
||||
updateView(usfOpen = checked as Boolean)
|
||||
true
|
||||
}
|
||||
switchExpose.setOnPreferenceChangeListener { _, checked ->
|
||||
updateSafWrite(usfExpose = checked as Boolean)
|
||||
VolumeProvider.notifyRootsChanged(requireContext())
|
||||
true
|
||||
}
|
||||
|
||||
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
|
||||
if (checked as Boolean) {
|
||||
(requireActivity() as SettingsActivity).notificationPermissionHelper.askAndRun {
|
||||
requireContext().let {
|
||||
if (AndroidUtils.isServiceRunning(it, KeepAliveService::class.java)) {
|
||||
ContextCompat.startForegroundService(it, Intent(it, KeepAliveService::class.java).apply {
|
||||
action = KeepAliveService.ACTION_FOREGROUND
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
findPreference<ListPreference>("export_method")!!.setOnPreferenceChangeListener { _, newValue ->
|
||||
if (newValue as String == "memory" && !Compat.isMemFileSupported()) {
|
||||
if (!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))
|
||||
.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
|
||||
}
|
||||
EncryptedFileProvider.exportMethod = EncryptedFileProvider.ExportMethod.parse(newValue)
|
||||
}
|
||||
VolumeProvider.usfExpose = checked
|
||||
updateView(usfExpose = checked)
|
||||
VolumeProvider.notifyRootChanged(requireContext())
|
||||
true
|
||||
}
|
||||
switchSafWrite.setOnPreferenceChangeListener { _, checked ->
|
||||
VolumeProvider.usfSafWrite = checked as Boolean
|
||||
TemporaryFileProvider.usfSafWrite = checked
|
||||
true
|
||||
}
|
||||
}
|
||||
|
@ -8,19 +8,10 @@ import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.UUID
|
||||
|
||||
class VolumeData(
|
||||
val uuid: String,
|
||||
val name: String,
|
||||
val isHidden: Boolean = false,
|
||||
val type: Byte,
|
||||
var encryptedHash: ByteArray? = null,
|
||||
var iv: ByteArray? = null
|
||||
) : Parcelable {
|
||||
class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte, var encryptedHash: ByteArray? = null, var iv: ByteArray? = null): Parcelable {
|
||||
|
||||
constructor(parcel: Parcel) : this(
|
||||
parcel.readString()!!,
|
||||
parcel.readString()!!,
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte(),
|
||||
@ -32,7 +23,12 @@ class VolumeData(
|
||||
File(name).name
|
||||
}
|
||||
|
||||
fun getFullPath(filesDir: String) = getFullPath(name, isHidden, filesDir)
|
||||
fun getFullPath(filesDir: String): String {
|
||||
return if (isHidden)
|
||||
getHiddenVolumeFullPath(filesDir, name)
|
||||
else
|
||||
name
|
||||
}
|
||||
|
||||
fun canRead(filesDir: String): Boolean {
|
||||
val volumePath = getFullPath(filesDir)
|
||||
@ -66,7 +62,6 @@ class VolumeData(
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
with (dest) {
|
||||
writeString(uuid)
|
||||
writeString(name)
|
||||
writeByte(if (isHidden) 1 else 0)
|
||||
writeByte(type)
|
||||
@ -79,7 +74,7 @@ class VolumeData(
|
||||
if (other !is VolumeData) {
|
||||
return false
|
||||
}
|
||||
return other.uuid == uuid || (other.name == name && other.isHidden == isHidden)
|
||||
return other.name == name && other.isHidden == isHidden
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@ -95,17 +90,8 @@ class VolumeData(
|
||||
override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size)
|
||||
}
|
||||
|
||||
fun newUuid(): String = UUID.randomUUID().toString()
|
||||
|
||||
fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
|
||||
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name)
|
||||
}
|
||||
|
||||
fun getFullPath(name: String, isHidden: Boolean, filesDir: String): String {
|
||||
return if (isHidden)
|
||||
getHiddenVolumeFullPath(filesDir, name)
|
||||
else
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
@ -10,69 +10,46 @@ import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.PathUtils
|
||||
import java.io.File
|
||||
|
||||
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 6) {
|
||||
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 5) {
|
||||
companion object {
|
||||
private const val TAG = "VolumeDatabase"
|
||||
private const val TABLE_NAME = "Volumes"
|
||||
private const val COLUMN_UUID = "uuid"
|
||||
private const val COLUMN_NAME = "name"
|
||||
private const val COLUMN_HIDDEN = "hidden"
|
||||
private const val COLUMN_TYPE = "type"
|
||||
private const val COLUMN_HASH = "hash"
|
||||
private const val COLUMN_IV = "iv"
|
||||
}
|
||||
const val TAG = "VolumeDatabase"
|
||||
const val TABLE_NAME = "Volumes"
|
||||
const val COLUMN_NAME = "name"
|
||||
const val COLUMN_HIDDEN = "hidden"
|
||||
const val COLUMN_TYPE = "type"
|
||||
const val COLUMN_HASH = "hash"
|
||||
const val COLUMN_IV = "iv"
|
||||
|
||||
private fun createTable(db: SQLiteDatabase) =
|
||||
private fun contentValuesFromVolume(volume: VolumeData): ContentValues {
|
||||
val contentValues = ContentValues()
|
||||
contentValues.put(COLUMN_NAME, volume.name)
|
||||
contentValues.put(COLUMN_HIDDEN, volume.isHidden)
|
||||
contentValues.put(COLUMN_TYPE, byteArrayOf(volume.type))
|
||||
contentValues.put(COLUMN_HASH, volume.encryptedHash)
|
||||
contentValues.put(COLUMN_IV, volume.iv)
|
||||
return contentValues
|
||||
}
|
||||
}
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
|
||||
"$COLUMN_UUID TEXT PRIMARY KEY," +
|
||||
"$COLUMN_NAME TEXT," +
|
||||
"$COLUMN_NAME TEXT PRIMARY KEY," +
|
||||
"$COLUMN_HIDDEN SHORT," +
|
||||
"$COLUMN_TYPE BLOB," +
|
||||
"$COLUMN_HASH BLOB," +
|
||||
"$COLUMN_IV BLOB" +
|
||||
");"
|
||||
)
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
createTable(db)
|
||||
File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()
|
||||
}
|
||||
|
||||
override fun onOpen(db: SQLiteDatabase) {
|
||||
//check if database has been corrupted by v2.1.1
|
||||
val cursor = db.rawQuery("SELECT * FROM $TABLE_NAME WHERE $COLUMN_TYPE IS NULL;", null)
|
||||
if (cursor.count > 0) {
|
||||
Log.w(TAG, "Found ${cursor.count} corrupted volumes")
|
||||
while (cursor.moveToNext()) {
|
||||
// fix columns left shift
|
||||
val uuid = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)+5)
|
||||
val name = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)-1)
|
||||
val isHidden = cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)-1) == 1.toShort()
|
||||
val type = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE)-1)[0]
|
||||
val hash = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)-1)
|
||||
val iv = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_IV)-1)
|
||||
if (db.delete(TABLE_NAME, "$COLUMN_IV=?", arrayOf(uuid)) < 1) {
|
||||
Log.e(TAG, "Failed to remove volume $name")
|
||||
}
|
||||
if (db.insert(TABLE_NAME, null, ContentValues().apply {
|
||||
put(COLUMN_UUID, uuid)
|
||||
put(COLUMN_NAME, name)
|
||||
put(COLUMN_HIDDEN, isHidden)
|
||||
put(COLUMN_TYPE, byteArrayOf(type))
|
||||
put(COLUMN_HASH, hash)
|
||||
put(COLUMN_IV, iv)
|
||||
}) < 0) {
|
||||
Log.e(TAG, "Failed to insert volume $name")
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
|
||||
private fun getNewVolumePath(volumeName: String): File {
|
||||
return File(
|
||||
VolumeData.getFullPath(volumeName, true, context.filesDir.path)
|
||||
VolumeData(
|
||||
volumeName,
|
||||
true,
|
||||
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE
|
||||
).getFullPath(context.filesDir.path)
|
||||
).canonicalFile
|
||||
}
|
||||
|
||||
@ -124,37 +101,10 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
|
||||
}
|
||||
}
|
||||
}
|
||||
if (oldVersion < 6) {
|
||||
val cursor = db.rawQuery("SELECT $COLUMN_NAME FROM $TABLE_NAME;", null)
|
||||
val volumeNames = arrayOfNulls<String>(cursor.count)
|
||||
var i = 0
|
||||
while (cursor.moveToNext()) {
|
||||
volumeNames[i++] = cursor.getString(0)
|
||||
}
|
||||
cursor.close()
|
||||
if (volumeNames.isEmpty()) {
|
||||
db.execSQL("DROP TABLE $TABLE_NAME;")
|
||||
createTable(db)
|
||||
} else {
|
||||
db.execSQL("ALTER TABLE $TABLE_NAME RENAME TO OLD;")
|
||||
createTable(db)
|
||||
val uuidsValues = volumeNames.indices.joinToString(", ") { "('${VolumeData.newUuid()}', ?)" }
|
||||
// add uuids to old data
|
||||
db.execSQL(
|
||||
"INSERT INTO $TABLE_NAME " +
|
||||
"WITH uuids($COLUMN_UUID, $COLUMN_NAME) AS (VALUES $uuidsValues) " +
|
||||
"SELECT $COLUMN_UUID, OLD.$COLUMN_NAME, $COLUMN_HIDDEN, $COLUMN_TYPE, $COLUMN_HASH, $COLUMN_IV " +
|
||||
"FROM OLD JOIN uuids ON OLD.name = uuids.name;",
|
||||
volumeNames
|
||||
)
|
||||
db.execSQL("DROP TABLE OLD;")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractVolumeData(cursor: Cursor): VolumeData {
|
||||
return VolumeData(
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)),
|
||||
cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(),
|
||||
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE))[0],
|
||||
@ -192,14 +142,7 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
|
||||
|
||||
fun saveVolume(volume: VolumeData): Boolean {
|
||||
if (!isVolumeSaved(volume.name, volume.isHidden)) {
|
||||
return (writableDatabase.insert(TABLE_NAME, null, ContentValues().apply {
|
||||
put(COLUMN_UUID, volume.uuid)
|
||||
put(COLUMN_NAME, volume.name)
|
||||
put(COLUMN_HIDDEN, volume.isHidden)
|
||||
put(COLUMN_TYPE, byteArrayOf(volume.type))
|
||||
put(COLUMN_HASH, volume.encryptedHash)
|
||||
put(COLUMN_IV, volume.iv)
|
||||
}) >= 0.toLong())
|
||||
return (writableDatabase.insert(TABLE_NAME, null, contentValuesFromVolume(volume)) >= 0.toLong())
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -214,8 +157,8 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
|
||||
return list
|
||||
}
|
||||
|
||||
fun isHashSaved(volume: VolumeData): Boolean {
|
||||
val cursor = readableDatabase.rawQuery("SELECT $COLUMN_HASH FROM $TABLE_NAME WHERE $COLUMN_UUID=?", arrayOf(volume.uuid))
|
||||
fun isHashSaved(volumeName: String): Boolean {
|
||||
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME, COLUMN_HASH), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null)
|
||||
var isHashSaved = false
|
||||
if (cursor.moveToNext()) {
|
||||
if (cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)) != null) {
|
||||
@ -227,33 +170,32 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
|
||||
}
|
||||
|
||||
fun addHash(volume: VolumeData): Boolean {
|
||||
return writableDatabase.update(TABLE_NAME, ContentValues().apply {
|
||||
put(COLUMN_HASH, volume.encryptedHash)
|
||||
put(COLUMN_IV, volume.iv)
|
||||
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
|
||||
return writableDatabase.update(TABLE_NAME, contentValuesFromVolume(volume), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
|
||||
}
|
||||
|
||||
fun removeHash(volume: VolumeData): Boolean {
|
||||
return writableDatabase.update(
|
||||
TABLE_NAME,
|
||||
ContentValues().apply {
|
||||
put(COLUMN_HASH, null as ByteArray?)
|
||||
put(COLUMN_IV, null as ByteArray?)
|
||||
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)
|
||||
) > 0
|
||||
TABLE_NAME, contentValuesFromVolume(
|
||||
VolumeData(
|
||||
volume.name,
|
||||
volume.isHidden,
|
||||
volume.type,
|
||||
null,
|
||||
null
|
||||
)
|
||||
), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
|
||||
}
|
||||
|
||||
fun renameVolume(volume: VolumeData, newName: String): Boolean {
|
||||
return writableDatabase.update(
|
||||
TABLE_NAME,
|
||||
fun renameVolume(oldName: String, newName: String): Boolean {
|
||||
return writableDatabase.update(TABLE_NAME,
|
||||
ContentValues().apply {
|
||||
put(COLUMN_NAME, newName)
|
||||
},
|
||||
"$COLUMN_UUID=?", arrayOf(volume.uuid)
|
||||
"$COLUMN_NAME=?",arrayOf(oldName)
|
||||
) > 0
|
||||
}
|
||||
|
||||
fun removeVolume(volume: VolumeData): Boolean {
|
||||
return writableDatabase.delete(TABLE_NAME, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
|
||||
fun removeVolume(volumeName: String): Boolean {
|
||||
return writableDatabase.delete(TABLE_NAME, "$COLUMN_NAME=?", arrayOf(volumeName)) > 0
|
||||
}
|
||||
}
|
@ -1,20 +1,15 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.DocumentsContract
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import sushi.hardcore.droidfs.content_providers.VolumeProvider
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.util.Observable
|
||||
|
||||
class VolumeManager(private val context: Context): Observable<VolumeManager.Observer>() {
|
||||
interface Observer {
|
||||
fun onVolumeStateChanged(volume: VolumeData) {}
|
||||
fun onAllVolumesClosed() {}
|
||||
}
|
||||
|
||||
class VolumeManager(private val context: Context) {
|
||||
private var id = 0
|
||||
private val volumes = HashMap<Int, EncryptedVolume>()
|
||||
private val volumesData = HashMap<VolumeData, Int>()
|
||||
@ -23,8 +18,7 @@ class VolumeManager(private val context: Context): Observable<VolumeManager.Obse
|
||||
fun insert(volume: EncryptedVolume, data: VolumeData): Int {
|
||||
volumes[id] = volume
|
||||
volumesData[data] = id
|
||||
observers.forEach { it.onVolumeStateChanged(data) }
|
||||
VolumeProvider.notifyRootsChanged(context)
|
||||
VolumeProvider.notifyRootChanged(context)
|
||||
return id++
|
||||
}
|
||||
|
||||
@ -44,8 +38,6 @@ class VolumeManager(private val context: Context): Observable<VolumeManager.Obse
|
||||
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 }
|
||||
}
|
||||
@ -53,23 +45,21 @@ class VolumeManager(private val context: Context): Observable<VolumeManager.Obse
|
||||
fun closeVolume(id: Int) {
|
||||
volumes.remove(id)?.let { volume ->
|
||||
scopes[id]?.cancel()
|
||||
volume.closeVolume()
|
||||
volumesData.filter { it.value == id }.forEach { entry ->
|
||||
volumesData.remove(entry.key)
|
||||
observers.forEach { it.onVolumeStateChanged(entry.key) }
|
||||
volume.close()
|
||||
volumesData.filter { it.value == id }.forEach {
|
||||
volumesData.remove(it.key)
|
||||
}
|
||||
VolumeProvider.notifyRootsChanged(context)
|
||||
VolumeProvider.notifyRootChanged(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeAll() {
|
||||
volumes.forEach {
|
||||
scopes[it.key]?.cancel()
|
||||
it.value.closeVolume()
|
||||
it.value.close()
|
||||
}
|
||||
volumes.clear()
|
||||
volumesData.clear()
|
||||
observers.forEach { it.onAllVolumesClosed() }
|
||||
VolumeProvider.notifyRootsChanged(context)
|
||||
VolumeProvider.notifyRootChanged(context)
|
||||
}
|
||||
}
|
@ -1,88 +1,40 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.content.SharedPreferences
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.preference.PreferenceManager
|
||||
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
|
||||
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||
|
||||
class VolumeManagerApp : Application(), DefaultLifecycleObserver {
|
||||
companion object {
|
||||
const val ACTION_CLOSE_ALL_VOLUMES = "close_all"
|
||||
private const val USF_KEEP_OPEN_KEY = "usf_keep_open"
|
||||
}
|
||||
|
||||
private val closingServiceIntent by lazy {
|
||||
Intent(this, ClosingService::class.java)
|
||||
lateinit var sharedPreferences: SharedPreferences
|
||||
private val sharedPreferencesListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == USF_KEEP_OPEN_KEY) {
|
||||
reloadUsfKeepOpen()
|
||||
}
|
||||
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
|
||||
private var usfKeepOpen = false
|
||||
var isExporting = false
|
||||
var isStartingExternalApp = false
|
||||
val volumeManager = VolumeManager(this).also {
|
||||
it.observe(object : VolumeManager.Observer {
|
||||
override fun onVolumeStateChanged(volume: VolumeData) {
|
||||
updateServicesStates()
|
||||
}
|
||||
|
||||
override fun onAllVolumesClosed() {
|
||||
stopKeepAliveService()
|
||||
// closingService should not be running when this callback is triggered
|
||||
}
|
||||
})
|
||||
}
|
||||
val volumeManager = VolumeManager(this)
|
||||
|
||||
override fun onCreate() {
|
||||
super<Application>.onCreate()
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
AndroidUtils.LiveBooleanPreference.init(this, usfBackgroundDelegate, usfKeepOpenDelegate)
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this).apply {
|
||||
registerOnSharedPreferenceChangeListener(sharedPreferencesListener)
|
||||
}
|
||||
reloadUsfKeepOpen()
|
||||
}
|
||||
|
||||
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))
|
||||
private fun reloadUsfKeepOpen() {
|
||||
usfKeepOpen = sharedPreferences.getBoolean(USF_KEEP_OPEN_KEY, false)
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
@ -91,10 +43,10 @@ class VolumeManagerApp : Application(), DefaultLifecycleObserver {
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (!isStartingExternalApp) {
|
||||
if (!usfBackground) {
|
||||
if (!usfKeepOpen) {
|
||||
volumeManager.closeAll()
|
||||
}
|
||||
if (!usfBackground || !isExporting) {
|
||||
if (!usfKeepOpen || !isExporting) {
|
||||
TemporaryFileProvider.instance.wipe()
|
||||
}
|
||||
}
|
||||
|
@ -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.UIUtils
|
||||
import sushi.hardcore.droidfs.util.WidgetUtil
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.util.*
|
||||
|
||||
@ -123,7 +123,7 @@ class VolumeOpener(
|
||||
apply()
|
||||
}
|
||||
}
|
||||
val password = UIUtils.encodeEditTextContent(dialogBinding!!.editPassword)
|
||||
val password = WidgetUtil.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.closeVolume()
|
||||
encryptedVolume.close()
|
||||
isClosed = true
|
||||
}
|
||||
Arrays.fill(returnedHash.value!!, 0)
|
||||
|
17
app/src/main/java/sushi/hardcore/droidfs/WiperService.kt
Normal file
17
app/src/main/java/sushi/hardcore/droidfs/WiperService.kt
Normal file
@ -0,0 +1,17 @@
|
||||
package sushi.hardcore.droidfs
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
|
||||
class WiperService : Service() {
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
(application as VolumeManagerApp).volumeManager.closeAll()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
@ -40,12 +40,6 @@ abstract class SelectableAdapter<T>(private val onSelectionChanged: (Int) -> Uni
|
||||
return true
|
||||
}
|
||||
|
||||
fun unselect(position: Int) {
|
||||
selectedItems.remove(position)
|
||||
onSelectionChanged(selectedItems.size)
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
for (i in getItems().indices) {
|
||||
if (!selectedItems.contains(i) && isSelectable(i)) {
|
||||
|
@ -29,16 +29,6 @@ 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 {
|
||||
@ -76,7 +66,7 @@ class VolumeAdapter(
|
||||
false
|
||||
}
|
||||
|
||||
fun onVolumeDataChanged(position: Int) {
|
||||
fun onVolumeChanged(position: Int) {
|
||||
reloadVolumes()
|
||||
notifyItemChanged(position)
|
||||
}
|
||||
|
@ -1,16 +1,6 @@
|
||||
package sushi.hardcore.droidfs.add_volume
|
||||
|
||||
import sushi.hardcore.droidfs.R
|
||||
|
||||
enum class Action {
|
||||
OPEN,
|
||||
ADD,
|
||||
CREATE,
|
||||
;
|
||||
|
||||
fun getStringResId() = when (this) {
|
||||
OPEN -> R.string.open
|
||||
ADD -> R.string.add_volume
|
||||
CREATE -> R.string.create_volume
|
||||
}
|
||||
}
|
@ -67,18 +67,18 @@ class AddVolumeActivity: BaseActivity() {
|
||||
finish()
|
||||
}
|
||||
|
||||
fun onVolumeAdded() {
|
||||
fun onVolumeSelected(volume: VolumeData, rememberVolume: Boolean) {
|
||||
if (rememberVolume) {
|
||||
setResult(RESULT_USER_BACK)
|
||||
finish()
|
||||
}
|
||||
|
||||
fun openVolume(volume: VolumeData, isVolumeKnown: Boolean) {
|
||||
volumeOpener.openVolume(volume, isVolumeKnown, object : VolumeOpener.VolumeOpenerCallbacks {
|
||||
} else {
|
||||
volumeOpener.openVolume(volume, false, object : VolumeOpener.VolumeOpenerCallbacks {
|
||||
override fun onVolumeOpened(id: Int) {
|
||||
startExplorer(id, volume.shortName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun createVolume(volumePath: String, isHidden: Boolean, rememberVolume: Boolean) {
|
||||
supportFragmentManager
|
||||
|
@ -7,37 +7,25 @@ import android.text.InputType
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.RadioButton
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.children
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import sushi.hardcore.droidfs.BuildConfig
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.FingerprintProtector
|
||||
import sushi.hardcore.droidfs.LoadingTask
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.Theme
|
||||
import sushi.hardcore.droidfs.VolumeData
|
||||
import sushi.hardcore.droidfs.VolumeDatabase
|
||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||
import sushi.hardcore.droidfs.databinding.FileSystemRadioBinding
|
||||
import sushi.hardcore.droidfs.*
|
||||
import sushi.hardcore.droidfs.databinding.FragmentCreateVolumeBinding
|
||||
import sushi.hardcore.droidfs.filesystems.CryfsVolume
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
||||
import sushi.hardcore.droidfs.util.Compat
|
||||
import sushi.hardcore.droidfs.util.ObjRef
|
||||
import sushi.hardcore.droidfs.util.UIUtils
|
||||
import sushi.hardcore.droidfs.util.WidgetUtil
|
||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||
import java.io.File
|
||||
import java.util.Arrays
|
||||
import java.util.*
|
||||
|
||||
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"
|
||||
@ -46,17 +34,6 @@ class CreateVolumeFragment: Fragment() {
|
||||
private const val KEY_PIN_PASSWORDS = Constants.PIN_PASSWORDS_KEY
|
||||
private const val KEY_USF_FINGERPRINT = "fingerprint"
|
||||
|
||||
private val GOCRYPTFS_INFO = FileSystemInfo(
|
||||
R.string.gocryptfs,
|
||||
R.string.gocryptfs_details,
|
||||
R.array.gocryptfs_encryption_ciphers,
|
||||
)
|
||||
private val CRYFS_INFO = FileSystemInfo(
|
||||
R.string.cryfs,
|
||||
R.string.cryfs_details,
|
||||
R.array.cryfs_encryption_ciphers,
|
||||
)
|
||||
|
||||
fun newInstance(
|
||||
theme: Theme,
|
||||
volumePath: String,
|
||||
@ -80,7 +57,7 @@ class CreateVolumeFragment: Fragment() {
|
||||
|
||||
private lateinit var binding: FragmentCreateVolumeBinding
|
||||
private lateinit var theme: Theme
|
||||
private val fileSystemInfos = ArrayList<FileSystemInfo>(2)
|
||||
private val volumeTypes = ArrayList<String>(2)
|
||||
private lateinit var volumePath: String
|
||||
private var isHiddenVolume: Boolean = false
|
||||
private var rememberVolume: Boolean = false
|
||||
@ -115,10 +92,17 @@ class CreateVolumeFragment: Fragment() {
|
||||
binding.checkboxSavePassword.visibility = View.GONE
|
||||
}
|
||||
if (!BuildConfig.GOCRYPTFS_DISABLED) {
|
||||
fileSystemInfos.add(GOCRYPTFS_INFO)
|
||||
volumeTypes.add(resources.getString(R.string.gocryptfs))
|
||||
}
|
||||
if (!BuildConfig.CRYFS_DISABLED) {
|
||||
fileSystemInfos.add(CRYFS_INFO)
|
||||
volumeTypes.add(resources.getString(R.string.cryfs))
|
||||
}
|
||||
binding.spinnerVolumeType.adapter = ArrayAdapter(
|
||||
requireContext(),
|
||||
android.R.layout.simple_spinner_item,
|
||||
volumeTypes
|
||||
).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
val encryptionCipherAdapter = ArrayAdapter(
|
||||
requireContext(),
|
||||
@ -127,29 +111,19 @@ class CreateVolumeFragment: Fragment() {
|
||||
).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
for ((i, fs) in fileSystemInfos.iterator().withIndex()) {
|
||||
with(FileSystemRadioBinding.inflate(layoutInflater)) {
|
||||
title.text = getString(fs.nameResource)
|
||||
details.text = getString(fs.detailsResource)
|
||||
radio.isChecked = i == 0
|
||||
root.setOnClickListener {
|
||||
radio.performClick()
|
||||
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
|
||||
}
|
||||
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
|
||||
addAll(resources.getStringArray(ciphersArray).asList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.radioGroupFilesystems.addView(root)
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
binding.spinnerCipher.adapter = encryptionCipherAdapter
|
||||
if (pinPasswords) {
|
||||
@ -171,18 +145,9 @@ class CreateVolumeFragment: Fragment() {
|
||||
(activity as AddVolumeActivity).onFragmentLoaded(false)
|
||||
}
|
||||
|
||||
private fun getSelectedFileSystemIndex(): Int {
|
||||
for ((i, child) in binding.radioGroupFilesystems.children.iterator().withIndex()) {
|
||||
if (child.findViewById<RadioButton>(R.id.radio).isChecked) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun createVolume() {
|
||||
val password = UIUtils.encodeEditTextContent(binding.editPassword)
|
||||
val passwordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
|
||||
val password = WidgetUtil.encodeEditTextContent(binding.editPassword)
|
||||
val passwordConfirm = WidgetUtil.encodeEditTextContent(binding.editPasswordConfirm)
|
||||
if (!password.contentEquals(passwordConfirm)) {
|
||||
Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
|
||||
Arrays.fill(password, 0)
|
||||
@ -208,11 +173,11 @@ class CreateVolumeFragment: Fragment() {
|
||||
val volumeFile = File(volumePath)
|
||||
if (!volumeFile.exists())
|
||||
volumeFile.mkdirs()
|
||||
val result = if (fileSystemInfos[getSelectedFileSystemIndex()] == GOCRYPTFS_INFO) {
|
||||
val result = if (volumeTypes[binding.spinnerVolumeType.selectedItemPosition] == resources.getString(R.string.gocryptfs)) {
|
||||
val xchacha = when (binding.spinnerCipher.selectedItemPosition) {
|
||||
0 -> -1 // auto
|
||||
1 -> 0 // AES-GCM
|
||||
else -> 1 // XChaCha20-Poly1305
|
||||
0 -> 0
|
||||
1 -> 1
|
||||
else -> -1
|
||||
}
|
||||
generateResult(GocryptfsVolume.createAndOpenVolume(
|
||||
volumePath,
|
||||
@ -246,11 +211,11 @@ class CreateVolumeFragment: Fragment() {
|
||||
.show()
|
||||
} else {
|
||||
val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath
|
||||
val volume = VolumeData(VolumeData.newUuid(), volumeName, isHiddenVolume, result)
|
||||
val volume = VolumeData(volumeName, isHiddenVolume, result)
|
||||
var isVolumeSaved = false
|
||||
volumeDatabase.apply {
|
||||
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
|
||||
removeVolume(volume)
|
||||
removeVolume(volumeName)
|
||||
if (rememberVolume) {
|
||||
isVolumeSaved = saveVolume(volume)
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ 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
|
||||
@ -16,14 +15,10 @@ 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
|
||||
@ -40,10 +35,6 @@ 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"
|
||||
@ -83,7 +74,6 @@ 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,
|
||||
@ -103,13 +93,17 @@ 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 {
|
||||
updateUi()
|
||||
showRightSection()
|
||||
refreshStatus(binding.editVolumeName.text)
|
||||
}
|
||||
binding.buttonPickDirectory.setOnClickListener {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
@ -143,41 +137,22 @@ 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) {
|
||||
updateUi(s)
|
||||
refreshStatus(s)
|
||||
}
|
||||
})
|
||||
binding.switchRemember.setOnCheckedChangeListener { _, _ -> updateUi() }
|
||||
binding.editVolumeName.setOnEditorActionListener { _, _, _ ->
|
||||
if (binding.editVolumeName.text.isEmpty()) {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
if (binding.switchHiddenVolume.isChecked) R.string.empty_volume_name else R.string.empty_volume_path,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
onPathSelected()
|
||||
}
|
||||
true
|
||||
}
|
||||
binding.switchRemember.setOnCheckedChangeListener { _, _ -> refreshButtonText() }
|
||||
binding.editVolumeName.setOnEditorActionListener { _, _, _ -> onPathSelected(); true }
|
||||
binding.buttonAction.setOnClickListener { onPathSelected() }
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
(activity as AddVolumeActivity).onFragmentLoaded(true)
|
||||
showRightSection()
|
||||
}
|
||||
|
||||
private fun launchPickDirectory() {
|
||||
@ -185,82 +160,70 @@ class SelectPathFragment: Fragment() {
|
||||
PathUtils.safePickDirectory(pickDirectory, requireContext(), theme)
|
||||
}
|
||||
|
||||
private fun updateUi(volumeName: CharSequence = binding.editVolumeName.text) {
|
||||
var warning = -1
|
||||
fun updateWarning() {
|
||||
if (warning == -1) {
|
||||
binding.textWarning.isVisible = false
|
||||
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.textWarning.isVisible = true
|
||||
binding.textWarning.text = getString(warning)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
val hidden = binding.switchHiddenVolume.isChecked
|
||||
binding.editVolumeName.isVisible = hidden || inputViewModel.showEditText
|
||||
binding.buttonPickDirectory.isVisible = !hidden
|
||||
binding.textOr.isVisible = !hidden && !inputViewModel.showEditText
|
||||
binding.buttonEnterPath.isVisible = !hidden && !inputViewModel.showEditText
|
||||
if (hidden) {
|
||||
binding.textLabel.text = getString(R.string.volume_name_label)
|
||||
binding.editVolumeName.hint = getString(R.string.volume_name_hint)
|
||||
private fun refreshButtonText() {
|
||||
binding.buttonAction.text = getString(
|
||||
if (pickMode || volumeAction == Action.ADD) {
|
||||
if (binding.switchRemember.isChecked || currentVolumeData != null) {
|
||||
R.string.add_volume
|
||||
} else {
|
||||
binding.textLabel.text = getString(R.string.volume_path_label)
|
||||
binding.editVolumeName.hint = getString(R.string.volume_path_hint)
|
||||
R.string.open_volume
|
||||
}
|
||||
if (hidden && volumeName.contains(PathUtils.SEPARATOR)) {
|
||||
warning = R.string.error_slash_in_name
|
||||
} else {
|
||||
R.string.create_volume
|
||||
}
|
||||
// exit early if possible to avoid filesystem queries
|
||||
if (volumeName.isEmpty() || warning != -1 || (!hidden && !inputViewModel.showEditText)) {
|
||||
binding.buttonAction.isVisible = false
|
||||
binding.switchRemember.isVisible = false
|
||||
updateWarning()
|
||||
return
|
||||
)
|
||||
}
|
||||
|
||||
private fun refreshStatus(content: CharSequence) {
|
||||
val path = File(getCurrentVolumePath())
|
||||
volumeAction = if (path.isDirectory) {
|
||||
if (path.list()?.isEmpty() == true) {
|
||||
Action.CREATE
|
||||
} else if (pickMode || !binding.switchRemember.isChecked) {
|
||||
Action.OPEN
|
||||
} else {
|
||||
Action.ADD
|
||||
}
|
||||
if (path.list()?.isEmpty() == true || content.isEmpty()) Action.CREATE else Action.ADD
|
||||
} else {
|
||||
Action.CREATE
|
||||
}
|
||||
val valid = !(volumeAction == Action.CREATE && pickMode)
|
||||
binding.switchRemember.isVisible = valid
|
||||
binding.buttonAction.isVisible = valid
|
||||
if (valid) {
|
||||
binding.buttonAction.text = getString(volumeAction!!.getStringResId())
|
||||
currentVolumeData = if (volumeAction == Action.CREATE) {
|
||||
null
|
||||
} else {
|
||||
volumeDatabase.getVolume(volumeName.toString(), hidden)
|
||||
}
|
||||
if (currentVolumeData != null) {
|
||||
warning = R.string.volume_alread_saved
|
||||
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 {
|
||||
warning = R.string.choose_existing_volume
|
||||
refreshButtonText()
|
||||
binding.buttonAction.isEnabled = true
|
||||
if (currentVolumeData == null) {
|
||||
View.GONE
|
||||
} else {
|
||||
binding.textWarning.text = getString(R.string.volume_alread_saved)
|
||||
View.VISIBLE
|
||||
}
|
||||
}
|
||||
updateWarning()
|
||||
}
|
||||
|
||||
private fun onDirectoryPicked(uri: Uri) {
|
||||
val path = PathUtils.getFullPathFromTreeUri(uri, requireContext())
|
||||
if (path == null) {
|
||||
if (path != null)
|
||||
binding.editVolumeName.setText(path)
|
||||
else
|
||||
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 {
|
||||
@ -280,7 +243,13 @@ class SelectPathFragment: Fragment() {
|
||||
if (currentVolumeData == null) { // volume not known
|
||||
val currentVolumeValue = binding.editVolumeName.text.toString()
|
||||
val isHidden = binding.switchHiddenVolume.isChecked
|
||||
if (isHidden && currentVolumeValue.contains(PathUtils.SEPARATOR)) {
|
||||
if (currentVolumeValue.isEmpty()) {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
if (isHidden) R.string.enter_volume_name else R.string.enter_volume_path,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else if (isHidden && currentVolumeValue.contains(PathUtils.SEPARATOR)) {
|
||||
Toast.makeText(requireContext(), R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
|
||||
} else if (isHidden && volumeAction == Action.CREATE) {
|
||||
CustomAlertDialogBuilder(requireContext(), theme)
|
||||
@ -294,19 +263,14 @@ class SelectPathFragment: Fragment() {
|
||||
onNewVolumeSelected(currentVolumeValue, isHidden)
|
||||
}
|
||||
} else {
|
||||
with (activity as AddVolumeActivity) {
|
||||
if (volumeAction!! == Action.OPEN) {
|
||||
openVolume(currentVolumeData!!, true)
|
||||
} else {
|
||||
onVolumeAdded()
|
||||
}
|
||||
}
|
||||
(activity as AddVolumeActivity).onVolumeSelected(currentVolumeData!!, true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNewVolumeSelected(currentVolumeValue: String, isHidden: Boolean) {
|
||||
val volumePath = getCurrentVolumePath()
|
||||
if (volumeAction!! == Action.CREATE) {
|
||||
when (volumeAction!!) {
|
||||
Action.CREATE -> {
|
||||
val volumeFile = File(volumePath)
|
||||
var goodDirectory = false
|
||||
if (volumeFile.isFile) {
|
||||
@ -321,15 +285,10 @@ class SelectPathFragment: Fragment() {
|
||||
errorDirectoryNotWritable(volumePath)
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.dir_not_empty, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
Toast.makeText(requireContext(), R.string.dir_not_empty, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
R.string.listdir_null_error_msg,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
Toast.makeText(requireContext(), R.string.listdir_null_error_msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
if (File(PathUtils.getParentPath(volumePath)).canWrite()) {
|
||||
@ -339,13 +298,10 @@ class SelectPathFragment: Fragment() {
|
||||
}
|
||||
}
|
||||
if (goodDirectory) {
|
||||
(activity as AddVolumeActivity).createVolume(
|
||||
volumePath,
|
||||
isHidden,
|
||||
binding.switchRemember.isChecked
|
||||
)
|
||||
(activity as AddVolumeActivity).createVolume(volumePath, isHidden, binding.switchRemember.isChecked)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
Action.ADD -> {
|
||||
val volumeType = EncryptedVolume.getVolumeType(volumePath)
|
||||
if (volumeType < 0) {
|
||||
CustomAlertDialogBuilder(requireContext(), theme)
|
||||
@ -357,7 +313,7 @@ class SelectPathFragment: Fragment() {
|
||||
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.ok) { _, _ -> onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
|
||||
.setPositiveButton(R.string.ok) { _, _ -> addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
|
||||
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext())) {
|
||||
dialog.setView(
|
||||
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
|
||||
@ -370,7 +326,8 @@ class SelectPathFragment: Fragment() {
|
||||
}
|
||||
dialog.show()
|
||||
} else {
|
||||
onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
|
||||
addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -392,17 +349,11 @@ class SelectPathFragment: Fragment() {
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun onExistingVolumeSelected(volumeName: String, isHidden: Boolean, volumeType: Byte) {
|
||||
val volumeData = VolumeData(VolumeData.newUuid(), volumeName, isHidden, volumeType)
|
||||
private fun addVolume(volumeName: String, isHidden: Boolean, volumeType: Byte) {
|
||||
val volumeData = VolumeData(volumeName, isHidden, volumeType)
|
||||
if (binding.switchRemember.isChecked) {
|
||||
volumeDatabase.saveVolume(volumeData)
|
||||
}
|
||||
with (activity as AddVolumeActivity) {
|
||||
if (volumeAction!! == Action.OPEN) {
|
||||
openVolume(volumeData, binding.switchRemember.isChecked)
|
||||
} else {
|
||||
onVolumeAdded()
|
||||
}
|
||||
}
|
||||
(activity as AddVolumeActivity).onVolumeSelected(volumeData, binding.switchRemember.isChecked)
|
||||
}
|
||||
}
|
@ -2,7 +2,6 @@ package sushi.hardcore.droidfs.content_providers
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
@ -10,6 +9,7 @@ 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
|
||||
@ -17,7 +17,6 @@ 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,10 +35,9 @@ 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>()
|
||||
@ -47,7 +45,8 @@ class TemporaryFileProvider : ContentProvider() {
|
||||
override fun onCreate(): Boolean {
|
||||
return context?.let {
|
||||
volumeManager = (it.applicationContext as VolumeManagerApp).volumeManager
|
||||
usfSafWriteDelegate.init(it)
|
||||
usfSafWrite =
|
||||
PreferenceManager.getDefaultSharedPreferences(it).getBoolean("usf_saf_write", false)
|
||||
encryptedFileProvider = EncryptedFileProvider(it)
|
||||
instance = this
|
||||
val tmpFilesDir = EncryptedFileProvider.getTmpFilesDir(it)
|
||||
@ -136,7 +135,6 @@ class TemporaryFileProvider : ContentProvider() {
|
||||
|
||||
// this must not be cancelled
|
||||
fun wipe() = GlobalScope.launch(Dispatchers.IO) {
|
||||
context!!.revokeUriPermission(BASE_URI, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
synchronized(this@TemporaryFileProvider) {
|
||||
for (i in files.values) {
|
||||
i.file.free()
|
||||
|
@ -1,6 +1,7 @@
|
||||
package sushi.hardcore.droidfs.content_providers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.os.CancellationSignal
|
||||
@ -18,7 +19,6 @@ 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
|
||||
|
||||
@ -41,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) {
|
||||
fun notifyRootChanged(context: Context) {
|
||||
context.contentResolver.notifyChange(DocumentsContract.buildRootsUri(AUTHORITY), null)
|
||||
}
|
||||
}
|
||||
|
||||
private val usfExposeDelegate = AndroidUtils.LiveBooleanPreference("usf_expose", false)
|
||||
private val usfExpose by usfExposeDelegate
|
||||
private val usfSafWriteDelegate = AndroidUtils.LiveBooleanPreference("usf_saf_write", false)
|
||||
private val usfSafWrite by usfSafWriteDelegate
|
||||
private lateinit var volumeManager: VolumeManager
|
||||
private val volumes = HashMap<String, Pair<Int, VolumeData>>()
|
||||
private lateinit var encryptedFileProvider: EncryptedFileProvider
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
val context = (context ?: return false)
|
||||
AndroidUtils.LiveBooleanPreference.init(context, usfExposeDelegate, usfSafWriteDelegate)
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
usfExpose = sharedPreferences.getBoolean("usf_expose", false)
|
||||
usfSafWrite = sharedPreferences.getBoolean("usf_saf_write", false)
|
||||
volumeManager = (context.applicationContext as VolumeManagerApp).volumeManager
|
||||
encryptedFileProvider = EncryptedFileProvider(context)
|
||||
return true
|
||||
@ -66,6 +66,7 @@ class VolumeProvider: DocumentsProvider() {
|
||||
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||
val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
|
||||
if (!usfExpose) return cursor
|
||||
val previousVolumeIds = volumes.keys.toSet()
|
||||
volumes.clear()
|
||||
for (volume in volumeManager.listVolumes()) {
|
||||
var flags = DocumentsContract.Root.FLAG_LOCAL_ONLY or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
|
||||
@ -77,9 +78,18 @@ class VolumeProvider: DocumentsProvider() {
|
||||
add(DocumentsContract.Root.COLUMN_FLAGS, flags)
|
||||
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.icon_document_provider)
|
||||
add(DocumentsContract.Root.COLUMN_TITLE, volume.second.name)
|
||||
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, volume.second.uuid)
|
||||
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, volume.first)
|
||||
}
|
||||
volumes[volume.first.toString()] = volume
|
||||
}
|
||||
for (i in previousVolumeIds) {
|
||||
if (!volumes.containsKey(i)) {
|
||||
val uri = DocumentsContract.buildRootUri(AUTHORITY, i)
|
||||
context?.revokeUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
volumes[volume.second.uuid] = volume
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
@ -178,32 +188,6 @@ class VolumeProvider: DocumentsProvider() {
|
||||
return cursor
|
||||
}
|
||||
|
||||
class LazyExportedFile(
|
||||
private val encryptedFileProvider: EncryptedFileProvider,
|
||||
private val encryptedVolume: EncryptedVolume,
|
||||
path: String,
|
||||
) : EncryptedFileProvider.ExportedFile(path) {
|
||||
|
||||
private val exportedFile: EncryptedFileProvider.ExportedFile by lazy {
|
||||
val size = encryptedVolume.getAttr(path)?.size ?: run {
|
||||
Log.e(TAG, "stat() failed")
|
||||
throw RuntimeException("stat() failed")
|
||||
}
|
||||
val exportedFile = encryptedFileProvider.createFile(path, size) ?: run {
|
||||
Log.e(TAG, "Can't create exported file")
|
||||
throw RuntimeException("Can't create exported file")
|
||||
}
|
||||
if (!encryptedFileProvider.exportFile(exportedFile, encryptedVolume)) {
|
||||
Log.e(TAG, "File export failed")
|
||||
throw RuntimeException("File export failed")
|
||||
}
|
||||
exportedFile
|
||||
}
|
||||
|
||||
override fun open(mode: Int, furtive: Boolean) = exportedFile.open(mode, furtive)
|
||||
override fun free() = exportedFile.free()
|
||||
}
|
||||
|
||||
override fun openDocument(
|
||||
documentId: String,
|
||||
mode: String,
|
||||
@ -211,11 +195,20 @@ class VolumeProvider: DocumentsProvider() {
|
||||
): ParcelFileDescriptor? {
|
||||
if (!usfExpose) return null
|
||||
val document = parseDocumentId(documentId) ?: return null
|
||||
|
||||
val lazyExportedFile = LazyExportedFile(encryptedFileProvider, document.encryptedVolume, document.path)
|
||||
|
||||
val size = document.encryptedVolume.getAttr(document.path)?.size ?: run {
|
||||
Log.e(TAG, "stat() failed")
|
||||
return null
|
||||
}
|
||||
val exportedFile = encryptedFileProvider.createFile(document.path, size) ?: run {
|
||||
Log.e(TAG, "Can't create exported file")
|
||||
return null
|
||||
}
|
||||
if (!encryptedFileProvider.exportFile(exportedFile, document.encryptedVolume)) {
|
||||
Log.e(TAG, "File export failed")
|
||||
return null
|
||||
}
|
||||
val result = encryptedFileProvider.openFile(
|
||||
lazyExportedFile,
|
||||
exportedFile,
|
||||
mode,
|
||||
document.encryptedVolume,
|
||||
volumeManager.getCoroutineScope(document.volumeId),
|
||||
@ -237,21 +230,14 @@ class VolumeProvider: DocumentsProvider() {
|
||||
): String? {
|
||||
if (!usfExpose || !usfSafWrite) return null
|
||||
val document = parseDocumentId(parentDocumentId) ?: return null
|
||||
val path = PathUtils.pathJoin(document.path, displayName)
|
||||
var success = false
|
||||
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
success = document.encryptedVolume.mkdir(path)
|
||||
} else {
|
||||
val f = document.encryptedVolume.openFileWriteMode(path)
|
||||
if (f != -1L) {
|
||||
document.encryptedVolume.closeFile(f)
|
||||
success = true
|
||||
}
|
||||
}
|
||||
return if (success) {
|
||||
document.rootId+path
|
||||
} else {
|
||||
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
|
||||
} else {
|
||||
document.encryptedVolume.closeFile(f)
|
||||
document.rootId+"/"+newFile
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,17 +1,20 @@
|
||||
package sushi.hardcore.droidfs.explorers
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.addCallback
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@ -20,12 +23,10 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.coroutines.withContext
|
||||
import sushi.hardcore.droidfs.BaseActivity
|
||||
import sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.EncryptedFileProvider
|
||||
@ -48,8 +49,6 @@ 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
|
||||
|
||||
@ -70,7 +69,6 @@ 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
|
||||
@ -81,7 +79,6 @@ 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
|
||||
@ -95,7 +92,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)!!.also { finishOnClose(it) }
|
||||
encryptedVolume = app.volumeManager.getVolume(volumeId)!!
|
||||
sortOrderEntries = resources.getStringArray(R.array.sort_orders_entries)
|
||||
sortOrderValues = resources.getStringArray(R.array.sort_orders_values)
|
||||
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||
@ -104,7 +101,6 @@ 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)
|
||||
@ -186,15 +182,21 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
}
|
||||
|
||||
protected open fun bindFileOperationService(){
|
||||
FileOperationService.bind(this) {
|
||||
fileOperationService = it
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFileViewer(cls: Class<*>, filePath: String) {
|
||||
val intent = Intent(this, cls).apply {
|
||||
putExtra("path", filePath)
|
||||
putExtra("volumeId", volumeId)
|
||||
putExtra("volume", encryptedVolume)
|
||||
putExtra("sortOrder", sortOrderValues[currentSortOrderIndex])
|
||||
}
|
||||
startActivity(intent)
|
||||
@ -257,27 +259,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
.show()
|
||||
}
|
||||
|
||||
protected fun createNewFile(callback: (Long) -> Unit) {
|
||||
EditTextDialog(this, R.string.enter_file_name) {
|
||||
if (it.isEmpty()) {
|
||||
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
|
||||
createNewFile(callback)
|
||||
} else {
|
||||
val filePath = PathUtils.pathJoin(currentDirectoryPath, it)
|
||||
val handleID = encryptedVolume.openFileWriteMode(filePath)
|
||||
if (handleID == -1L) {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.file_creation_failed)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
} else {
|
||||
callback(handleID)
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun setVolumeNameTitle() {
|
||||
titleText.text = getString(R.string.volume, volumeName)
|
||||
}
|
||||
@ -331,15 +312,17 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
}
|
||||
|
||||
private fun displayExplorerElements() {
|
||||
synchronized(this) {
|
||||
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
|
||||
}
|
||||
unselectAll(false)
|
||||
loader.isVisible = false
|
||||
recycler_view_explorer.isVisible = true
|
||||
explorerAdapter.explorerElements = explorerElements
|
||||
val sharedPrefsEditor = sharedPrefs.edit()
|
||||
sharedPrefsEditor.putString(Constants.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
|
||||
sharedPrefsEditor.apply()
|
||||
}
|
||||
|
||||
private suspend fun recursiveSetSize(directory: ExplorerElement) {
|
||||
yield()
|
||||
private fun recursiveSetSize(directory: ExplorerElement) {
|
||||
for (child in encryptedVolume.readDir(directory.fullPath) ?: return) {
|
||||
if (child.isDirectory) {
|
||||
recursiveSetSize(child)
|
||||
@ -363,25 +346,26 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
}
|
||||
}
|
||||
|
||||
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) = lifecycleScope.launch {
|
||||
directoryLoadingTask?.cancelAndJoin()
|
||||
recycler_view_explorer.isVisible = false
|
||||
loader.isVisible = true
|
||||
explorerElements = encryptedVolume.readDir(path) ?: return@launch
|
||||
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) {
|
||||
synchronized(this) {
|
||||
explorerElements = encryptedVolume.readDir(path) ?: return
|
||||
if (path != "/") {
|
||||
explorerElements.add(
|
||||
0,
|
||||
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
|
||||
)
|
||||
}
|
||||
}
|
||||
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.GONE
|
||||
currentDirectoryPath = path
|
||||
currentPathText.text = getString(R.string.location, currentDirectoryPath)
|
||||
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
|
||||
directoryLoadingTask = launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.IO) {
|
||||
synchronized(this@BaseExplorerActivity) {
|
||||
for (element in explorerElements) {
|
||||
if (element.isDirectory) {
|
||||
recursiveSetSize(element)
|
||||
@ -389,10 +373,11 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
totalSize += element.stat.size
|
||||
}
|
||||
}
|
||||
directoryLoadingTask!!.join()
|
||||
}
|
||||
displayExplorerElements()
|
||||
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
|
||||
onDisplayed?.invoke()
|
||||
}
|
||||
} else {
|
||||
displayExplorerElements()
|
||||
totalSizeText.text = getString(
|
||||
@ -575,6 +560,14 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMenuIconTint(menu: Menu, iconColor: Int, menuItemId: Int, drawableId: Int) {
|
||||
menu.findItem(menuItemId)?.let {
|
||||
it.icon = ContextCompat.getDrawable(this, drawableId)?.apply {
|
||||
setTint(iconColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menu.findItem(R.id.rename).isVisible = false
|
||||
menu.findItem(R.id.open_as)?.isVisible = false
|
||||
@ -582,10 +575,9 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
menu.findItem(R.id.external_open)?.isVisible = false
|
||||
}
|
||||
val noItemSelected = explorerAdapter.selectedItems.isEmpty()
|
||||
with(UIUtils.getMenuIconNeutralTint(this, menu)) {
|
||||
applyTo(R.id.sort, R.drawable.icon_sort)
|
||||
applyTo(R.id.share, R.drawable.icon_share)
|
||||
}
|
||||
val iconColor = ContextCompat.getColor(this, R.color.neutralIconTint)
|
||||
setMenuIconTint(menu, iconColor, R.id.sort, R.drawable.icon_sort)
|
||||
setMenuIconTint(menu, iconColor, R.id.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
|
||||
@ -615,13 +607,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
.setTitle(R.string.sort_order)
|
||||
.setSingleChoiceItems(sortOrderEntries, currentSortOrderIndex) { dialog, which ->
|
||||
currentSortOrderIndex = which
|
||||
// displayExplorerElements must not be called if directoryLoadingTask is active
|
||||
if (directoryLoadingTask?.isActive != true) {
|
||||
displayExplorerElements()
|
||||
}
|
||||
val sharedPrefsEditor = sharedPrefs.edit()
|
||||
sharedPrefsEditor.putString(Constants.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
|
||||
sharedPrefsEditor.apply()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
@ -674,6 +660,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
||||
if (app.isStartingExternalApp) {
|
||||
TemporaryFileProvider.instance.wipe()
|
||||
}
|
||||
if (encryptedVolume.isClosed()) {
|
||||
finish()
|
||||
} else {
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -68,11 +68,7 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||
if (uris != null) {
|
||||
for (uri in uris) {
|
||||
try {
|
||||
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
} catch (e: SecurityException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
importFilesFromUris(uris) {
|
||||
onImportComplete(uris)
|
||||
@ -181,6 +177,7 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
"importFromOtherVolumes" -> {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.action = "pick"
|
||||
intent.putExtra("volume", encryptedVolume)
|
||||
pickFromOtherVolumes.launch(intent)
|
||||
}
|
||||
"importFiles" -> {
|
||||
@ -192,11 +189,9 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
pickImportDirectory.launch(null)
|
||||
}
|
||||
"createFile" -> {
|
||||
createNewFile {
|
||||
encryptedVolume.closeFile(it)
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
EditTextDialog(this, R.string.enter_file_name) {
|
||||
createNewFile(it)
|
||||
}.show()
|
||||
}
|
||||
"createFolder" -> {
|
||||
openDialogCreateFolder()
|
||||
@ -204,7 +199,7 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
"camera" -> {
|
||||
val intent = Intent(this, CameraActivity::class.java)
|
||||
intent.putExtra("path", currentDirectoryPath)
|
||||
intent.putExtra("volumeId", volumeId)
|
||||
intent.putExtra("volume", encryptedVolume)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
@ -224,6 +219,26 @@ 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)
|
||||
@ -328,8 +343,8 @@ class ExplorerActivity : BaseExplorerActivity() {
|
||||
activityScope.launch {
|
||||
onTaskResult(
|
||||
fileOperationService.moveElements(volumeId, toMove, toClean),
|
||||
R.string.move_failed,
|
||||
R.string.move_success,
|
||||
R.string.move_failed,
|
||||
)
|
||||
setCurrentPath(currentDirectoryPath)
|
||||
}
|
||||
|
@ -9,8 +9,6 @@ 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() {
|
||||
|
||||
@ -32,15 +30,15 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
|
||||
return when (item.itemId) {
|
||||
R.id.validate -> {
|
||||
val extras = intent.extras
|
||||
val success = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
|
||||
val errorMsg: String? = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SEND -> {
|
||||
val uri = IntentUtils.getParcelableExtra<Uri>(intent, Intent.EXTRA_STREAM)
|
||||
if (uri == null) {
|
||||
false
|
||||
getString(R.string.share_intent_parsing_failed)
|
||||
} else {
|
||||
importFilesFromUris(listOf(uri), ::onImported)
|
||||
true
|
||||
null
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
@ -52,34 +50,20 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
|
||||
}
|
||||
if (uris != null) {
|
||||
importFilesFromUris(uris, ::onImported)
|
||||
true
|
||||
null
|
||||
} else {
|
||||
false
|
||||
getString(R.string.share_intent_parsing_failed)
|
||||
}
|
||||
}
|
||||
else -> false
|
||||
else -> getString(R.string.share_intent_parsing_failed)
|
||||
}
|
||||
} else if ((intent.clipData?.itemCount ?: 0) > 0) {
|
||||
val byteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(intent.clipData!!.getItemAt(0).text))
|
||||
val byteArray = ByteArray(byteBuffer.remaining())
|
||||
byteBuffer.get(byteArray)
|
||||
val size = byteArray.size.toLong()
|
||||
createNewFile {
|
||||
var offset = 0L
|
||||
while (offset < size) {
|
||||
offset += encryptedVolume.write(it, offset, byteArray, offset, size-offset)
|
||||
}
|
||||
encryptedVolume.closeFile(it)
|
||||
onImported()
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
getString(R.string.share_intent_parsing_failed)
|
||||
}
|
||||
if (!success) {
|
||||
errorMsg?.let {
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(R.string.share_intent_parsing_failed)
|
||||
.setMessage(it)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
@ -0,0 +1,5 @@
|
||||
package sushi.hardcore.droidfs.file_operations
|
||||
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class FileOperationNotification(val notificationBuilder: NotificationCompat.Builder, val notificationId: Int)
|
@ -1,183 +1,64 @@
|
||||
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
|
||||
}
|
||||
|
||||
inner class PendingTask<T>(
|
||||
val title: Int,
|
||||
val total: Int?,
|
||||
private val getTask: (Int) -> Deferred<T>,
|
||||
private val onStart: (taskId: Int, job: Deferred<T>) -> Unit,
|
||||
) {
|
||||
fun start(taskId: Int): Deferred<T> = getTask(taskId).also { onStart(taskId, it) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "FileOperationService"
|
||||
const val NOTIFICATION_CHANNEL_ID = "FileOperations"
|
||||
const val ACTION_CANCEL = "file_operation_cancel"
|
||||
|
||||
/**
|
||||
* Bind to the service.
|
||||
*
|
||||
* Registers an [ActivityResultLauncher] in the provided activity to request notification permission. Consequently, the activity must not yet be started.
|
||||
*
|
||||
* The activity must stay running while calling the service's methods.
|
||||
*
|
||||
* If multiple activities bind simultaneously, only the latest one will be used by the service.
|
||||
*/
|
||||
fun bind(activity: BaseActivity, onBound: (FileOperationService) -> Unit) {
|
||||
val helper = AndroidUtils.NotificationPermissionHelper(activity)
|
||||
lateinit var service: FileOperationService
|
||||
val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, binder: IBinder) {
|
||||
onBound((binder as FileOperationService.LocalBinder).getService().also {
|
||||
service = it
|
||||
it.notificationPermissionHelpers.addLast(helper)
|
||||
})
|
||||
}
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {}
|
||||
}
|
||||
activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
activity.unbindService(serviceConnection)
|
||||
// Could have been more efficient with a LinkedHashMap but the JDK implementation doesn't allow
|
||||
// to access the latest element in O(1) unless using reflection
|
||||
service.notificationPermissionHelpers.removeAll { it.activity == activity }
|
||||
}
|
||||
})
|
||||
activity.bindService(
|
||||
Intent(activity, FileOperationService::class.java),
|
||||
serviceConnection,
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
override fun onBind(p0: Intent?): IBinder {
|
||||
volumeManger = (application as VolumeManagerApp).volumeManager
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onBind(p0: Intent?): IBinder = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startPendingTask { id, notification ->
|
||||
// on service start, the pending task is the foreground task
|
||||
setForeground(id, notification)
|
||||
}
|
||||
isStarted = true
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isStarted = false
|
||||
}
|
||||
|
||||
private fun processPendingTask() {
|
||||
if (isStarted) {
|
||||
startPendingTask { id, notification ->
|
||||
if (foregroundNotificationId == -1) {
|
||||
// service started but not in foreground yet
|
||||
setForeground(id, notification)
|
||||
} else {
|
||||
// already running in foreground, just add a new notification
|
||||
notificationManager.notify(id, notification)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ContextCompat.startForegroundService(
|
||||
this,
|
||||
Intent(this, FileOperationService::class.java)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the pending task and create an associated notification.
|
||||
*/
|
||||
private fun startPendingTask(showNotification: (id: Int, Notification) -> Unit) {
|
||||
val task = pendingTask
|
||||
pendingTask = null
|
||||
if (task == null) {
|
||||
Log.w(TAG, "Started without pending task")
|
||||
return
|
||||
}
|
||||
private fun showNotification(message: Int, total: Int?): FileOperationNotification {
|
||||
++lastNotificationId
|
||||
if (!::notificationManager.isInitialized){
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
}
|
||||
@ -191,7 +72,8 @@ class FileOperationService : Service() {
|
||||
)
|
||||
}
|
||||
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(getString(task.title))
|
||||
notificationBuilder
|
||||
.setContentTitle(getString(message))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
.addAction(NotificationCompat.Action(
|
||||
@ -199,179 +81,78 @@ class FileOperationService : Service() {
|
||||
getString(R.string.cancel),
|
||||
PendingIntent.getBroadcast(
|
||||
this,
|
||||
newTaskId,
|
||||
0,
|
||||
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||
putExtra("bundle", Bundle().apply {
|
||||
putBinder("binder", LocalBinder())
|
||||
putInt("taskId", newTaskId)
|
||||
})
|
||||
val bundle = Bundle()
|
||||
bundle.putBinder("binder", LocalBinder())
|
||||
bundle.putInt("notificationId", lastNotificationId)
|
||||
putExtra("bundle", bundle)
|
||||
action = ACTION_CANCEL
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
))
|
||||
if (task.total != null) {
|
||||
if (total != null) {
|
||||
notificationBuilder
|
||||
.setContentText("0/${task.total}")
|
||||
.setProgress(task.total, 0, false)
|
||||
.setContentText("0/$total")
|
||||
.setProgress(total, 0, false)
|
||||
} else {
|
||||
notificationBuilder
|
||||
.setContentText(getString(R.string.discovering_files))
|
||||
.setProgress(0, 0, true)
|
||||
}
|
||||
showNotification(newTaskId, notificationBuilder.build())
|
||||
notifications[newTaskId] = notificationBuilder
|
||||
tasks[newTaskId] = task.start(newTaskId)
|
||||
newTaskId++
|
||||
notificationManager.notify(lastNotificationId, notificationBuilder.build())
|
||||
return FileOperationNotification(notificationBuilder, lastNotificationId)
|
||||
}
|
||||
|
||||
private fun setForeground(id: Int, notification: Notification) {
|
||||
ServiceCompat.startForeground(this, id, notification,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
} else {
|
||||
0
|
||||
}
|
||||
)
|
||||
foregroundNotificationId = id
|
||||
}
|
||||
|
||||
private fun updateNotificationProgress(taskId: Int, progress: Int, total: Int) {
|
||||
val notificationBuilder = notifications[taskId] ?: return
|
||||
notificationBuilder
|
||||
private fun updateNotificationProgress(notification: FileOperationNotification, progress: Int, total: Int){
|
||||
notification.notificationBuilder
|
||||
.setProgress(total, progress, false)
|
||||
.setContentText("$progress/$total")
|
||||
notificationManager.notify(taskId, notificationBuilder.build())
|
||||
notificationManager.notify(notification.notificationId, notification.notificationBuilder.build())
|
||||
}
|
||||
|
||||
fun cancelOperation(taskId: Int) {
|
||||
tasks[taskId]?.cancel()
|
||||
private fun cancelNotification(notification: FileOperationNotification){
|
||||
notificationManager.cancel(notification.notificationId)
|
||||
}
|
||||
|
||||
fun cancelOperation(notificationId: Int){
|
||||
tasks[notificationId]?.cancel()
|
||||
}
|
||||
|
||||
private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
|
||||
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<out T> {
|
||||
tasks[notification.notificationId] = task
|
||||
return coroutineScope {
|
||||
withContext(serviceScope.coroutineContext) {
|
||||
try {
|
||||
TaskResult.completed(task.await())
|
||||
} catch (e: CancellationException) {
|
||||
onCancelled?.invoke()
|
||||
TaskResult.cancelled()
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
TaskResult.error(e.localizedMessage)
|
||||
} finally {
|
||||
notificationManager.cancel(taskId)
|
||||
notifications.remove(taskId)
|
||||
tasks.remove(taskId)
|
||||
if (tasks.size == 0) {
|
||||
// last task finished, remove from foreground state but don't stop the service
|
||||
ServiceCompat.stopForeground(this@FileOperationService, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
foregroundNotificationId = -1
|
||||
} else if (taskId == foregroundNotificationId) {
|
||||
// foreground task finished, falling back to the next one
|
||||
val entry = notifications.entries.first()
|
||||
setForeground(entry.key, entry.value.build())
|
||||
cancelNotification(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and run a new task until completion.
|
||||
*
|
||||
* Handles notification permission request, service startup and notification management.
|
||||
*
|
||||
* Overrides [pendingTask] without checking! (safe if user is not insanely fast)
|
||||
*/
|
||||
private suspend fun <T> newTask(
|
||||
title: Int,
|
||||
total: Int?,
|
||||
getTask: (taskId: Int) -> Deferred<T>,
|
||||
onCancelled: (suspend () -> Unit)?,
|
||||
): TaskResult<out T> {
|
||||
val startedTask = suspendCoroutine { continuation ->
|
||||
val task = PendingTask(title, total, getTask) { taskId, job ->
|
||||
continuation.resume(Pair(taskId, job))
|
||||
}
|
||||
pendingTask = task
|
||||
if (askForNotificationPermission) {
|
||||
with (notificationPermissionHelpers.last()) {
|
||||
askAndRun { granted ->
|
||||
if (granted) {
|
||||
processPendingTask()
|
||||
} else {
|
||||
CustomAlertDialogBuilder(activity, activity.theme)
|
||||
.setTitle(R.string.warning)
|
||||
.setMessage(R.string.notification_denied_msg)
|
||||
.setPositiveButton(R.string.settings) { _, _ ->
|
||||
(application as VolumeManagerApp).isStartingExternalApp = true
|
||||
activity.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", packageName, null)
|
||||
)
|
||||
)
|
||||
}
|
||||
.setNegativeButton(R.string.later, null)
|
||||
.setOnDismissListener { processPendingTask() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
askForNotificationPermission = false // only ask once per service instance
|
||||
return@suspendCoroutine
|
||||
}
|
||||
processPendingTask()
|
||||
}
|
||||
return waitForTask(startedTask.first, startedTask.second, onCancelled)
|
||||
}
|
||||
|
||||
private suspend fun <T> volumeTask(
|
||||
title: Int,
|
||||
total: Int?,
|
||||
volumeId: Int,
|
||||
task: suspend (taskId: Int, encryptedVolume: EncryptedVolume) -> T
|
||||
notification: FileOperationNotification,
|
||||
task: suspend (encryptedVolume: EncryptedVolume) -> T
|
||||
): TaskResult<out T> {
|
||||
return newTask(title, total, { taskId ->
|
||||
return waitForTask(
|
||||
notification,
|
||||
volumeManger.getCoroutineScope(volumeId).async {
|
||||
task(taskId, getEncryptedVolume(volumeId))
|
||||
task(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(
|
||||
@ -415,8 +196,9 @@ 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(R.string.file_op_copy_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
var failedItem: String? = null
|
||||
for (i in items.indices) {
|
||||
yield()
|
||||
@ -430,7 +212,7 @@ class FileOperationService : Service() {
|
||||
failedItem = items[i].srcPath
|
||||
}
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(taskId, i+1, items.size)
|
||||
updateNotificationProgress(notification, i+1, items.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@ -440,7 +222,8 @@ class FileOperationService : Service() {
|
||||
}
|
||||
|
||||
suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
|
||||
return volumeTask(R.string.file_op_move_msg, toMove.size, volumeId) { taskId, encryptedVolume ->
|
||||
val notification = showNotification(R.string.file_op_move_msg, toMove.size)
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
val total = toMove.size+toClean.size
|
||||
var failedItem: String? = null
|
||||
for ((i, item) in toMove.withIndex()) {
|
||||
@ -448,7 +231,7 @@ class FileOperationService : Service() {
|
||||
failedItem = item.srcPath
|
||||
break
|
||||
} else {
|
||||
updateNotificationProgress(taskId, i+1, total)
|
||||
updateNotificationProgress(notification, i+1, total)
|
||||
}
|
||||
}
|
||||
if (failedItem == null) {
|
||||
@ -457,7 +240,7 @@ class FileOperationService : Service() {
|
||||
failedItem = folderPath
|
||||
break
|
||||
} else {
|
||||
updateNotificationProgress(taskId, toMove.size+i+1, total)
|
||||
updateNotificationProgress(notification, toMove.size+i+1, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -469,7 +252,7 @@ class FileOperationService : Service() {
|
||||
encryptedVolume: EncryptedVolume,
|
||||
dstPaths: List<String>,
|
||||
uris: List<Uri>,
|
||||
taskId: Int,
|
||||
notification: FileOperationNotification,
|
||||
): String? {
|
||||
var failedIndex = -1
|
||||
for (i in dstPaths.indices) {
|
||||
@ -482,7 +265,7 @@ class FileOperationService : Service() {
|
||||
failedIndex = i
|
||||
}
|
||||
if (failedIndex == -1) {
|
||||
updateNotificationProgress(taskId, i+1, dstPaths.size)
|
||||
updateNotificationProgress(notification, i+1, dstPaths.size)
|
||||
} else {
|
||||
return uris[failedIndex].toString()
|
||||
}
|
||||
@ -491,8 +274,9 @@ class FileOperationService : Service() {
|
||||
}
|
||||
|
||||
suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
|
||||
return volumeTask(R.string.file_op_import_msg, dstPaths.size, volumeId) { taskId, encryptedVolume ->
|
||||
importFilesFromUris(encryptedVolume, dstPaths, uris, taskId)
|
||||
val notification = showNotification(R.string.file_op_import_msg, dstPaths.size)
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
importFilesFromUris(encryptedVolume, dstPaths, uris, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@ -500,6 +284,8 @@ 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,
|
||||
@ -530,35 +316,36 @@ 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(R.string.file_op_import_msg, null, volumeId) { taskId, encryptedVolume ->
|
||||
return ImportDirectoryResult(volumeTask(volumeId, notification) { 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 directory creation fails, check if it was already present
|
||||
if (!encryptedVolume.mkdir(dir) && encryptedVolume.getAttr(dir)?.type != Stat.S_IFDIR) {
|
||||
if (!encryptedVolume.mkdir(dir)) {
|
||||
failedItem = dir
|
||||
break
|
||||
}
|
||||
}
|
||||
if (failedItem == null) {
|
||||
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, taskId)
|
||||
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, notification)
|
||||
}
|
||||
failedItem
|
||||
}, srcUris)
|
||||
}
|
||||
|
||||
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
|
||||
return globalTask(R.string.file_op_wiping_msg, uris.size, { taskId ->
|
||||
val notification = showNotification(R.string.file_op_wiping_msg, uris.size)
|
||||
val task = serviceScope.async(Dispatchers.IO) {
|
||||
var errorMsg: String? = null
|
||||
for (i in uris.indices) {
|
||||
yield()
|
||||
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
|
||||
if (errorMsg == null) {
|
||||
updateNotificationProgress(taskId, i+1, uris.size)
|
||||
updateNotificationProgress(notification, i+1, uris.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@ -567,7 +354,8 @@ class FileOperationService : Service() {
|
||||
rootFile?.delete()
|
||||
}
|
||||
errorMsg
|
||||
})
|
||||
}
|
||||
return waitForTask(notification, task)
|
||||
}
|
||||
|
||||
private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
|
||||
@ -603,7 +391,8 @@ class FileOperationService : Service() {
|
||||
}
|
||||
|
||||
suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
|
||||
return volumeTask(R.string.file_op_export_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||
val notification = showNotification(R.string.file_op_export_msg, items.size)
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
|
||||
var failedItem: String? = null
|
||||
for (i in items.indices) {
|
||||
@ -618,7 +407,7 @@ class FileOperationService : Service() {
|
||||
}
|
||||
}
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(taskId, i+1, items.size)
|
||||
updateNotificationProgress(notification, i+1, items.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@ -647,7 +436,8 @@ class FileOperationService : Service() {
|
||||
}
|
||||
|
||||
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
|
||||
return volumeTask(R.string.file_op_delete_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||
val notification = showNotification(R.string.file_op_delete_msg, items.size)
|
||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
||||
var failedItem: String? = null
|
||||
for ((i, element) in items.withIndex()) {
|
||||
yield()
|
||||
@ -657,7 +447,7 @@ class FileOperationService : Service() {
|
||||
failedItem = element.fullPath
|
||||
}
|
||||
if (failedItem == null) {
|
||||
updateNotificationProgress(taskId, i + 1, items.size)
|
||||
updateNotificationProgress(notification, i + 1, items.size)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
@ -666,13 +456,13 @@ class FileOperationService : Service() {
|
||||
}.failedItem // treat cancellation as success
|
||||
}
|
||||
|
||||
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile): Int {
|
||||
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int {
|
||||
yield()
|
||||
val children = rootDirectory.listFiles()
|
||||
var count = children.size
|
||||
for (child in children) {
|
||||
if (child.isDirectory) {
|
||||
count += recursiveCountChildElements(child)
|
||||
count += recursiveCountChildElements(child, scope)
|
||||
}
|
||||
}
|
||||
return count
|
||||
@ -682,8 +472,9 @@ class FileOperationService : Service() {
|
||||
src: DocumentFile,
|
||||
dst: DocumentFile,
|
||||
dstRootDirectory: ObjRef<DocumentFile?>?,
|
||||
taskId: Int,
|
||||
notification: FileOperationNotification,
|
||||
total: Int,
|
||||
scope: CoroutineScope,
|
||||
progress: ObjRef<Int> = ObjRef(0)
|
||||
): DocumentFile? {
|
||||
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
|
||||
@ -700,10 +491,10 @@ class FileOperationService : Service() {
|
||||
inputStream.close()
|
||||
if (written != child.length()) return child
|
||||
} else {
|
||||
recursiveCopyVolume(child, dstDir, null, taskId, total, progress)?.let { return it }
|
||||
recursiveCopyVolume(child, dstDir, null, notification, total, scope, progress)?.let { return it }
|
||||
}
|
||||
progress.value++
|
||||
updateNotificationProgress(taskId, progress.value, total)
|
||||
updateNotificationProgress(notification, progress.value, total)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@ -711,14 +502,13 @@ 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 result = globalTask(R.string.copy_volume_notification, null, { taskId ->
|
||||
val total = recursiveCountChildElements(src)
|
||||
updateNotificationProgress(taskId, 0, total)
|
||||
recursiveCopyVolume(src, dst, dstRootDirectory, taskId, total)
|
||||
}, {
|
||||
dstRootDirectory.value?.delete()
|
||||
})
|
||||
return CopyVolumeResult(result, dstRootDirectory.value)
|
||||
val task = 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)
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
package sushi.hardcore.droidfs.file_operations
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class NotificationBroadcastReceiver: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == FileOperationService.ACTION_CANCEL){
|
||||
intent.getBundleExtra("bundle")?.let { bundle ->
|
||||
(bundle.getBinder("binder") as FileOperationService.LocalBinder?)?.let { binder ->
|
||||
val notificationId = bundle.getInt("notificationId")
|
||||
val service = binder.getService()
|
||||
service.cancelOperation(notificationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +1,8 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import sushi.hardcore.droidfs.databinding.ActivityAudioPlayerBinding
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class AudioPlayer: MediaPlayer(){
|
||||
private lateinit var binding: ActivityAudioPlayerBinding
|
||||
|
||||
|
@ -1,19 +1,15 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.C
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import 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 sushi.hardcore.droidfs.Constants
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
import kotlin.math.min
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class EncryptedVolumeDataSource(private val encryptedVolume: EncryptedVolume, private val filePath: String):
|
||||
DataSource {
|
||||
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
|
||||
|
@ -14,17 +14,14 @@ 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() {
|
||||
@ -33,8 +30,9 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
private lateinit var originalParentPath: String
|
||||
private lateinit var windowInsetsController: WindowInsetsControllerCompat
|
||||
private var windowTypeMask = 0
|
||||
protected val playlist = mutableListOf<ExplorerElement>()
|
||||
private val playlistMutex = Mutex()
|
||||
private var foldersFirst = true
|
||||
private var wasMapped = false
|
||||
protected val mappedPlaylist = mutableListOf<ExplorerElement>()
|
||||
protected var currentPlaylistIndex = -1
|
||||
private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
|
||||
|
||||
@ -42,10 +40,8 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
filePath = intent.getStringExtra("path")!!
|
||||
originalParentPath = PathUtils.getParentPath(filePath)
|
||||
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
|
||||
intent.getIntExtra("volumeId", -1)
|
||||
)!!
|
||||
finishOnClose(encryptedVolume)
|
||||
encryptedVolume = IntentUtils.getParcelableExtra(intent, "volume")!!
|
||||
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
|
||||
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
|
||||
windowTypeMask = typeMask
|
||||
@ -130,60 +126,58 @@ abstract class FileViewerActivity: BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
protected suspend fun createPlaylist() {
|
||||
playlistMutex.withLock {
|
||||
if (currentPlaylistIndex != -1) {
|
||||
// playlist already initialized
|
||||
return
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 }
|
||||
ExplorerElement.sortBy(sortOrder, foldersFirst, mappedPlaylist)
|
||||
//find current index
|
||||
for ((i, e) in mappedPlaylist.withIndex()){
|
||||
if (filePath == e.fullPath){
|
||||
currentPlaylistIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
wasMapped = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCurrentItem() {
|
||||
filePath = playlist[currentPlaylistIndex].fullPath
|
||||
}
|
||||
|
||||
protected suspend fun playlistNext(forward: Boolean) {
|
||||
protected fun playlistNext(forward: Boolean) {
|
||||
createPlaylist()
|
||||
currentPlaylistIndex = if (forward) {
|
||||
(currentPlaylistIndex + 1).mod(playlist.size)
|
||||
(currentPlaylistIndex+1)%mappedPlaylist.size
|
||||
} else {
|
||||
(currentPlaylistIndex - 1).mod(playlist.size)
|
||||
var x = (currentPlaylistIndex-1)%mappedPlaylist.size
|
||||
if (x < 0) {
|
||||
x += mappedPlaylist.size
|
||||
}
|
||||
updateCurrentItem()
|
||||
x
|
||||
}
|
||||
filePath = mappedPlaylist[currentPlaylistIndex].fullPath
|
||||
}
|
||||
|
||||
protected suspend fun deleteCurrentFile(): Boolean {
|
||||
createPlaylist() // ensure we know the current position in the playlist
|
||||
return if (encryptedVolume.deleteFile(filePath)) {
|
||||
playlist.removeAt(currentPlaylistIndex)
|
||||
if (playlist.size != 0) {
|
||||
if (currentPlaylistIndex == playlist.size) {
|
||||
// deleted the last element of the playlist, go back to the first
|
||||
currentPlaylistIndex = 0
|
||||
}
|
||||
updateCurrentItem()
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
protected fun refreshPlaylist() {
|
||||
mappedPlaylist.clear()
|
||||
wasMapped = false
|
||||
createPlaylist()
|
||||
}
|
||||
|
||||
protected fun goBackToExplorer() {
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (encryptedVolume.isClosed()) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,12 +12,10 @@ 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
|
||||
@ -107,15 +105,17 @@ class ImageViewer: FileViewerActivity() {
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.warning)
|
||||
.setPositiveButton(R.string.ok) { _, _ ->
|
||||
lifecycleScope.launch {
|
||||
if (deleteCurrentFile()) {
|
||||
if (playlist.size == 0) { // no more image left
|
||||
createPlaylist() //be sure the playlist is created before deleting if there is only one image
|
||||
if (encryptedVolume.deleteFile(filePath)) {
|
||||
playlistNext(true)
|
||||
refreshPlaylist()
|
||||
if (mappedPlaylist.size == 0) { //deleted all images of the playlist
|
||||
goBackToExplorer()
|
||||
} else {
|
||||
loadImage(true)
|
||||
}
|
||||
} else {
|
||||
CustomAlertDialogBuilder(this@ImageViewer, theme)
|
||||
CustomAlertDialogBuilder(this, theme)
|
||||
.keepFullScreen()
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(getString(R.string.remove_failed, fileName))
|
||||
@ -123,7 +123,6 @@ class ImageViewer: FileViewerActivity() {
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setMessage(getString(R.string.single_delete_confirm, fileName))
|
||||
.show()
|
||||
@ -200,7 +199,6 @@ class ImageViewer: FileViewerActivity() {
|
||||
}
|
||||
|
||||
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false){
|
||||
lifecycleScope.launch {
|
||||
playlistNext(deltaX < 0)
|
||||
loadImage(true)
|
||||
if (slideshowActive) {
|
||||
@ -210,7 +208,6 @@ class ImageViewer: FileViewerActivity() {
|
||||
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopSlideshow(){
|
||||
slideshowActive = false
|
||||
|
@ -1,24 +1,16 @@
|
||||
package sushi.hardcore.droidfs.file_viewers
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import kotlinx.coroutines.launch
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.video.VideoSize
|
||||
import sushi.hardcore.droidfs.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
|
||||
|
||||
@ -41,16 +33,12 @@ abstract class MediaPlayer: FileViewerActivity() {
|
||||
private fun initializePlayer(){
|
||||
player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
|
||||
bindPlayer(player)
|
||||
player.addMediaSource(createMediaSource(filePath))
|
||||
lifecycleScope.launch {
|
||||
createPlaylist()
|
||||
playlist.forEachIndexed { index, e ->
|
||||
if (index != currentPlaylistIndex) {
|
||||
player.addMediaSource(index, createMediaSource(e.fullPath))
|
||||
}
|
||||
}
|
||||
for (e in mappedPlaylist) {
|
||||
player.addMediaSource(createMediaSource(e.fullPath))
|
||||
}
|
||||
player.repeatMode = Player.REPEAT_MODE_ALL
|
||||
player.seekToDefaultPosition(currentPlaylistIndex)
|
||||
player.playWhenReady = true
|
||||
player.addListener(object : Player.Listener{
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
@ -73,13 +61,11 @@ abstract class MediaPlayer: FileViewerActivity() {
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
if (player.repeatMode != Player.REPEAT_MODE_ONE && currentPlaylistIndex != -1) {
|
||||
lifecycleScope.launch {
|
||||
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % player.mediaItemCount)
|
||||
if (player.repeatMode != Player.REPEAT_MODE_ONE) {
|
||||
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % mappedPlaylist.size)
|
||||
refreshFileName()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
player.prepare()
|
||||
}
|
||||
|
@ -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 androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
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(PlayerView.ControllerVisibilityListener { visibility ->
|
||||
binding.videoPlayer.setControllerVisibilityListener(StyledPlayerView.ControllerVisibilityListener { visibility ->
|
||||
binding.topBar.visibility = visibility
|
||||
if (visibility == View.VISIBLE) {
|
||||
showPartialSystemUi()
|
||||
|
@ -1,5 +1,6 @@
|
||||
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
|
||||
@ -100,6 +101,13 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
|
||||
}
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : this(parcel.readLong())
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) = with(parcel) {
|
||||
writeByte(CRYFS_VOLUME_TYPE)
|
||||
writeLong(fusePtr)
|
||||
}
|
||||
|
||||
override fun openFileReadMode(path: String): Long {
|
||||
return nativeOpen(fusePtr, path, 0)
|
||||
}
|
||||
|
@ -2,21 +2,18 @@ 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: Observable<EncryptedVolume.Observer>() {
|
||||
|
||||
interface Observer {
|
||||
fun onClose()
|
||||
}
|
||||
abstract class EncryptedVolume: Parcelable {
|
||||
|
||||
class InitResult(
|
||||
val errorCode: Int,
|
||||
@ -38,6 +35,18 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
|
||||
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.
|
||||
*
|
||||
@ -83,6 +92,8 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@ -96,14 +107,9 @@ abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
|
||||
abstract fun rmdir(path: String): Boolean
|
||||
abstract fun getAttr(path: String): Stat?
|
||||
abstract fun rename(srcPath: String, dstPath: String): Boolean
|
||||
protected abstract fun close()
|
||||
abstract fun close()
|
||||
abstract fun isClosed(): Boolean
|
||||
|
||||
fun closeVolume() {
|
||||
observers.forEach { it.onClose() }
|
||||
close()
|
||||
}
|
||||
|
||||
fun pathExists(path: String): Boolean {
|
||||
return getAttr(path) != null
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
package sushi.hardcore.droidfs.filesystems
|
||||
|
||||
import android.os.Parcel
|
||||
import android.util.Log
|
||||
import sushi.hardcore.droidfs.R
|
||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||
@ -99,6 +100,8 @@ 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()
|
||||
}
|
||||
@ -119,6 +122,11 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
|
||||
return native_get_attr(sessionID, path)
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) = with(parcel) {
|
||||
writeByte(GOCRYPTFS_VOLUME_TYPE)
|
||||
writeInt(sessionID)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
native_close(sessionID)
|
||||
}
|
||||
|
@ -1,110 +0,0 @@
|
||||
package sushi.hardcore.droidfs.util
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
object AndroidUtils {
|
||||
fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean {
|
||||
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
@Suppress("DEPRECATION")
|
||||
for (service in manager.getRunningServices(Int.MAX_VALUE)) {
|
||||
if (serviceClass.name == service.service.className) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Manifest.permission.POST_NOTIFICATIONS] permission helper.
|
||||
*
|
||||
* Must be initialized before [Activity.onCreate] finishes.
|
||||
*/
|
||||
class NotificationPermissionHelper<out A: AppCompatActivity>(val activity: A) {
|
||||
private var listener: ((Boolean) -> Unit)? = null
|
||||
private val launcher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
listener?.invoke(granted)
|
||||
listener = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for notification permission if required and run the provided callback.
|
||||
*
|
||||
* The callback is run as soon as the user dismisses the permission dialog,
|
||||
* no matter if the permission has been granted or not.
|
||||
*
|
||||
* If this function is called again before the user answered the dialog from the
|
||||
* previous call, the previous callback won't be triggered.
|
||||
*
|
||||
* @param onDialogDismiss argument set to `true` if the permission is granted or
|
||||
* not required, `false` otherwise
|
||||
*/
|
||||
fun askAndRun(onDialogDismiss: (Boolean) -> Unit) {
|
||||
assert(listener == null)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
listener = onDialogDismiss
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
return
|
||||
}
|
||||
}
|
||||
onDialogDismiss(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Property delegate mirroring the state of a boolean value in shared preferences.
|
||||
*
|
||||
* [init] **must** be called before accessing the delegated property.
|
||||
*/
|
||||
class LiveBooleanPreference(
|
||||
private val key: String,
|
||||
private val defaultValue: Boolean = false,
|
||||
private val onChange: ((value: Boolean) -> Unit)? = null
|
||||
) {
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private var value = defaultValue
|
||||
private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == this.key) {
|
||||
reload()
|
||||
onChange?.invoke(value)
|
||||
}
|
||||
}
|
||||
|
||||
fun init(context: Context) = init(PreferenceManager.getDefaultSharedPreferences(context))
|
||||
|
||||
fun init(sharedPreferences: SharedPreferences) {
|
||||
this.sharedPreferences = sharedPreferences
|
||||
reload()
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
private fun reload() {
|
||||
value = sharedPreferences.getBoolean(key, defaultValue)
|
||||
}
|
||||
|
||||
operator fun getValue(thisRef: Any, property: KProperty<*>) = value
|
||||
|
||||
companion object {
|
||||
fun init(context: Context, vararg liveBooleanPreferences: LiveBooleanPreference) {
|
||||
init(PreferenceManager.getDefaultSharedPreferences(context), *liveBooleanPreferences)
|
||||
}
|
||||
|
||||
fun init(sharedPreferences: SharedPreferences, vararg liveBooleanPreferences: LiveBooleanPreference) {
|
||||
for (i in liveBooleanPreferences) {
|
||||
i.init(sharedPreferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
package sushi.hardcore.droidfs.util
|
||||
|
||||
import android.app.Activity
|
||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||
|
||||
abstract class Observable<T> {
|
||||
protected val observers = mutableListOf<T>()
|
||||
|
||||
fun observe(observer: T) {
|
||||
observers.add(observer)
|
||||
}
|
||||
}
|
||||
|
||||
fun Activity.finishOnClose(encryptedVolume: EncryptedVolume) {
|
||||
encryptedVolume.observe(object : EncryptedVolume.Observer {
|
||||
override fun onClose() {
|
||||
finish()
|
||||
// no need to remove observer as the EncryptedVolume will be destroyed
|
||||
}
|
||||
})
|
||||
}
|
@ -3,7 +3,6 @@ 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
|
||||
@ -112,8 +111,6 @@ object PathUtils {
|
||||
}
|
||||
}
|
||||
Log.d(PATH_RESOLVER_TAG, "getExternalFilesDirs failed")
|
||||
// Don't risk to be killed by SELinux on newer Android versions
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||
try {
|
||||
val process = ProcessBuilder("mount").redirectErrorStream(true).start().apply { waitFor() }
|
||||
process.inputStream.readBytes().decodeToString().split("\n").forEach { line ->
|
||||
@ -132,7 +129,6 @@ object PathUtils {
|
||||
e.printStackTrace()
|
||||
}
|
||||
Log.d(PATH_RESOLVER_TAG, "mount processing failed")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -1,45 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
package sushi.hardcore.droidfs.util
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Menu
|
||||
import android.widget.EditText
|
||||
import androidx.core.content.ContextCompat
|
||||
import sushi.hardcore.droidfs.R
|
||||
import java.nio.CharBuffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.*
|
||||
|
||||
object UIUtils {
|
||||
fun encodeEditTextContent(editText: EditText): ByteArray {
|
||||
val charArray = CharArray(editText.text.length)
|
||||
editText.text.getChars(0, editText.text.length, charArray, 0)
|
||||
val byteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(charArray))
|
||||
Arrays.fill(charArray, Char.MIN_VALUE)
|
||||
val byteArray = ByteArray(byteBuffer.remaining())
|
||||
byteBuffer.get(byteArray)
|
||||
Wiper.wipe(byteBuffer)
|
||||
return byteArray
|
||||
}
|
||||
|
||||
class MenuIconColor(
|
||||
private val context: Context,
|
||||
private val menu: Menu,
|
||||
private val color: Int
|
||||
) {
|
||||
fun applyTo(menuItemId: Int, drawableId: Int) {
|
||||
menu.findItem(menuItemId)?.let {
|
||||
it.icon = ContextCompat.getDrawable(context, drawableId)?.apply {
|
||||
setTint(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getMenuIconNeutralTint(context: Context, menu: Menu) = MenuIconColor(
|
||||
context, menu,
|
||||
ContextCompat.getColor(context, R.color.neutralIconTint),
|
||||
)
|
||||
}
|
@ -3,7 +3,7 @@ package sushi.hardcore.droidfs.util
|
||||
import java.lang.Integer.max
|
||||
|
||||
class Version(inputVersion: String) : Comparable<Version> {
|
||||
private val version: String
|
||||
val version: String
|
||||
|
||||
init {
|
||||
val regex = "[0-9]+(\\.[0-9]+)*".toRegex()
|
||||
@ -24,6 +24,4 @@ class Version(inputVersion: String) : Comparable<Version> {
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
override fun toString() = version
|
||||
}
|
19
app/src/main/java/sushi/hardcore/droidfs/util/WidgetUtil.kt
Normal file
19
app/src/main/java/sushi/hardcore/droidfs/util/WidgetUtil.kt
Normal file
@ -0,0 +1,19 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -185,14 +185,14 @@ internal class CircleClipTapView(context: Context, attrs: AttributeSet): View(co
|
||||
updatePathShape()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
override fun onDraw(canvas: Canvas?) {
|
||||
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)
|
||||
}
|
||||
}
|
@ -2,19 +2,29 @@ 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 androidx.media3.ui.PlayerView
|
||||
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
|
||||
|
||||
class DoubleTapPlayerView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : PlayerView(context, attrs, defStyleAttr) {
|
||||
) : StyledPlayerView(context, attrs, defStyleAttr) {
|
||||
|
||||
companion object {
|
||||
const val SEEK_SECONDS = 10
|
||||
@ -65,7 +75,22 @@ class DoubleTapPlayerView @JvmOverloads constructor(
|
||||
handler.postDelayed(stopDoubleTap, 700)
|
||||
}
|
||||
}
|
||||
private val gestureDetector = GestureDetector(context, gestureListener)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
@ -110,4 +135,35 @@ 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)
|
||||
}
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
@ -196,7 +196,6 @@ 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);
|
||||
@ -217,7 +216,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 (append_slash) {
|
||||
if (plain_dir[-2] != '/') {
|
||||
strcat(fullPath, "/");
|
||||
}
|
||||
strcat(fullPath, name);
|
||||
|
@ -40,7 +40,7 @@ struct Muxer {
|
||||
jmethodID seek_method_id;
|
||||
};
|
||||
|
||||
int write_packet(void* opaque, const uint8_t* buff, int buff_size) {
|
||||
int write_packet(void* opaque, 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};
|
||||
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);
|
||||
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);
|
||||
return stream->index;
|
||||
}
|
||||
|
||||
|
@ -1,5 +0,0 @@
|
||||
<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>
|
@ -1,5 +1,5 @@
|
||||
<vector android:height="24dp"
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
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="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="@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"/>
|
||||
</vector>
|
||||
|
@ -1,9 +0,0 @@
|
||||
<?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>
|
@ -15,11 +15,11 @@
|
||||
android:textSize="@dimen/title_text_size"
|
||||
android:padding="10dp"/>
|
||||
|
||||
<androidx.media3.ui.PlayerControlView
|
||||
<com.google.android.exoplayer2.ui.StyledPlayerControlView
|
||||
android:id="@+id/audio_controller"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:controller_layout_id="@layout/audio_exo_player_control_view"
|
||||
app:controller_layout_id="@layout/audio_exo_styled_player_control_view"
|
||||
app:show_timeout="0"
|
||||
app:show_shuffle_button="true"
|
||||
app:repeat_toggle_modes="all|one"/>
|
||||
|
@ -1,23 +0,0 @@
|
||||
<?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>
|
@ -26,20 +26,20 @@
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:background="@android:color/transparent"
|
||||
android:gravity="center"
|
||||
android:padding="@dimen/exo_styled_controls_padding"
|
||||
android:clipToPadding="false"
|
||||
android:layoutDirection="ltr"
|
||||
android:layout_marginBottom="-40dp">
|
||||
android:padding="@dimen/exo_styled_controls_padding"
|
||||
android:layout_marginBottom="-40dp"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<ImageButton android:id="@id/exo_prev"
|
||||
style="@style/ExoStyledControls.Button.Center.Previous"/>
|
||||
|
||||
<include layout="@layout/exo_player_control_rewind_button" />
|
||||
<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_player_control_ffwd_button" />
|
||||
<include layout="@layout/exo_styled_player_control_ffwd_button" />
|
||||
|
||||
<ImageButton android:id="@id/exo_next"
|
||||
style="@style/ExoStyledControls.Button.Center.Next"/>
|
30
app/src/main/res/layout/exo_center_controls.xml
Normal file
30
app/src/main/res/layout/exo_center_controls.xml
Normal file
@ -0,0 +1,30 @@
|
||||
<?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>
|
148
app/src/main/res/layout/exo_styled_player_control_view.xml
Normal file
148
app/src/main/res/layout/exo_styled_player_control_view.xml
Normal file
@ -0,0 +1,148 @@
|
||||
<?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>
|
@ -10,12 +10,6 @@
|
||||
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"
|
||||
|
@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:paddingVertical="@dimen/selectable_row_vertical_padding">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_marginEnd="10dp"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_toEndOf="@+id/radio"
|
||||
android:layout_alignParentEnd="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="@dimen/title_text_size"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/details"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/textColorSecondary"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
@ -3,25 +3,25 @@
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical">
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/volume_type_label"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
android:text="@string/volume_type_label"/>
|
||||
|
||||
<RadioGroup
|
||||
android:id="@+id/radio_group_filesystems"
|
||||
android:layout_width="match_parent"
|
||||
<Spinner
|
||||
android:id="@+id/spinner_volume_type"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="10dp"/>
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginVertical="@dimen/volume_operation_vertical_gap"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/password_label"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
android:text="@string/password_label"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_password"
|
||||
@ -30,15 +30,13 @@
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1"
|
||||
android:autofillHints="password"
|
||||
android:hint="@string/password"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
android:hint="@string/password"/>
|
||||
|
||||
<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_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
android:layout_marginTop="@dimen/volume_operation_vertical_gap"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/edit_password_confirm"
|
||||
@ -47,13 +45,11 @@
|
||||
android:inputType="textPassword"
|
||||
android:maxLines="1"
|
||||
android:autofillHints="password"
|
||||
android:hint="@string/password_confirmation_hint"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||
android:hint="@string/password_confirmation_hint"/>
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||
android:layout_marginVertical="@dimen/volume_operation_vertical_gap">
|
||||
|
||||
<TextView
|
||||
|
@ -13,7 +13,6 @@
|
||||
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
|
||||
@ -55,24 +54,44 @@
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||
android:text="@string/volume_path_label"
|
||||
android:layout_marginBottom="10dp"/>
|
||||
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="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||
android:hint="@string/volume_path_hint"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:layout_weight="0.5"
|
||||
android:inputType="text"
|
||||
android:maxLines="1"
|
||||
android:visibility="gone" />
|
||||
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>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_warning"
|
||||
@ -82,40 +101,12 @@
|
||||
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
|
||||
@ -125,7 +116,6 @@
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="@dimen/volume_operation_button_horizontal_margin"
|
||||
android:layout_marginTop="@dimen/volume_operation_vertical_gap"
|
||||
android:visibility="gone"
|
||||
android:text="@string/create_volume" />
|
||||
|
||||
</LinearLayout>
|
@ -1,11 +0,0 @@
|
||||
<?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>
|
@ -1,4 +1,24 @@
|
||||
<resources>
|
||||
<string-array name="gocryptfs_encryption_ciphers">
|
||||
<item>AES-GCM</item>
|
||||
<item>XChaCha20-Poly1305</item>
|
||||
<item>@string/auto</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="cryfs_encryption_ciphers">
|
||||
<item>xchacha20-poly1305</item>
|
||||
<item>aes-256-gcm</item>
|
||||
<item>aes-128-gcm</item>
|
||||
<item>twofish-256-gcm</item>
|
||||
<item>twofish-128-gcm</item>
|
||||
<item>serpent-256-gcm</item>
|
||||
<item>serpent-128-gcm</item>
|
||||
<item>cast-256-gcm</item>
|
||||
<item>mars-448-gcm</item>
|
||||
<item>mars-256-gcm</item>
|
||||
<item>mars-128-gcm</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="sort_orders_entries">
|
||||
<item>اسم</item>
|
||||
<item>حجم</item>
|
||||
|
@ -32,8 +32,8 @@
|
||||
<string name="storage_perm_denied_msg">إن DroidFS لا يمكنه العمل بدون صلاحبات التخزين.</string>
|
||||
<string name="get_size_failed">لقد فشلت عملية استرداد حجم الملف.</string>
|
||||
<string name="parent_folder">المجلد الأصلي</string>
|
||||
<string name="empty_volume_path">رجاءاً أدخل مسار المجلد المشفر</string>
|
||||
<string name="empty_volume_name">رجاءاً أدخل اسم المجلد المشفر</string>
|
||||
<string name="enter_volume_path">رجاءاً أدخل مسار المجلد المشفر</string>
|
||||
<string name="enter_volume_name">رجاءاً أدخل اسم المجلد المشفر</string>
|
||||
<string name="external_open">فتح بتطبيق خارجي</string>
|
||||
<string name="single_delete_confirm">هل أنت متأكد من حذف %s ?</string>
|
||||
<string name="multiple_delete_confirm">هل أنت متأكد من حذف هذه %s العناصر ?</string>
|
||||
@ -73,6 +73,7 @@
|
||||
<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>
|
||||
@ -251,20 +252,4 @@
|
||||
<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>
|
||||
|
@ -1,20 +0,0 @@
|
||||
<resources>
|
||||
<string-array name="sort_orders_entries">
|
||||
<item>Name</item>
|
||||
<item>Größe</item>
|
||||
<item>Datum</item>
|
||||
<item>Name (absteigend)</item>
|
||||
<item>Größe (absteigend)</item>
|
||||
<item>Datum (absteigend)</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="color_names">
|
||||
<item>Grün</item>
|
||||
<item>Rot</item>
|
||||
<item>Blau</item>
|
||||
<item>Gelb</item>
|
||||
<item>Orange</item>
|
||||
<item>Lila</item>
|
||||
<item>Rosa</item>
|
||||
</string-array>
|
||||
</resources>
|
@ -1,275 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">DroidFS</string>
|
||||
<string name="create_volume">Laufwerk erstellen</string>
|
||||
<string name="open">Öffnen</string>
|
||||
<string name="create">Erstellen</string>
|
||||
<string name="change_password">Passwort ändern</string>
|
||||
<string name="password">Passwort</string>
|
||||
<string name="import_files">Dateien importieren/verschlüsseln</string>
|
||||
<string name="import_folder">Ordner importieren/verschlüsseln</string>
|
||||
<string name="discovering_files">Dateien aufspüren…</string>
|
||||
<string name="mkdir">Ordner erstellen</string>
|
||||
<string name="dir_empty">Verzeichnis leeren</string>
|
||||
<string name="warning">Warnung!</string>
|
||||
<string name="ask_lock_volume">Sind Sie sicher, dass Sie dieses Volume sperren wollen?</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="cancel">Abbrechen</string>
|
||||
<string name="enter_folder_name">Ordnername:</string>
|
||||
<string name="error">Fehler</string>
|
||||
<string name="error_filename_empty">Bitte geben Sie einen Namen ein</string>
|
||||
<string name="error_mkdir">Ordnererstellung fehlgeschlagen.</string>
|
||||
<string name="success_import">Import erfolgreich.</string>
|
||||
<string name="success_import_msg">Die ausgewählten Dateien wurden erfolgreich importiert.</string>
|
||||
<string name="import_failed">Import von %s fehlgeschlagen.</string>
|
||||
<string name="export_failed">Export von %s fehlgeschlagen.</string>
|
||||
<string name="success_export">Export erfolgreich.</string>
|
||||
<string name="remove_failed">Löschung von %s fehlgeschlagen.</string>
|
||||
<string name="passwords_mismatch">Passwörter stimmen nicht überein</string>
|
||||
<string name="dir_not_empty">Das ausgewählte Verzeichnis ist nicht leer</string>
|
||||
<string name="create_volume_failed">Die Erstellung des Volumes ist fehlgeschlagen.</string>
|
||||
<string name="open_volume_failed">Öffnen fehlgeschlagen</string>
|
||||
<string name="share_chooser">Datei freigeben</string>
|
||||
<string name="storage_perm_denied">Speichererlaubnis verweigert</string>
|
||||
<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="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>
|
||||
<string name="location">Standort: %s</string>
|
||||
<string name="total_size">Gesamtgröße: %s</string>
|
||||
<string name="import_from_other_volume">Importieren von einem anderen Datenträger</string>
|
||||
<string name="read_file_failed">Diese Datei konnte nicht geöffnet werden.</string>
|
||||
<string name="volume">Volume: %s</string>
|
||||
<string name="yes">Ja</string>
|
||||
<string name="no">Nein</string>
|
||||
<string name="ask_for_wipe">Möchten Sie die Originaldateien löschen?</string>
|
||||
<string name="wipe_failed">Löschvorgang fehlgeschlagen: %s</string>
|
||||
<string name="wipe_successful">Dateien erfolgreich gelöscht.</string>
|
||||
<string name="rename">Umbenennen</string>
|
||||
<string name="rename_title">Neuer Name:</string>
|
||||
<string name="rename_failed">Fehlgeschlagene Umbenennung %s</string>
|
||||
<string name="sort_order">Sortierreihenfolge:</string>
|
||||
<string name="change_password_failed">Vorgang fehlgeschlagen. Bitte überprüfen Sie Ihr altes Passwort!</string>
|
||||
<string name="share_menu_label">Verschlüsseln mit DroidFS</string>
|
||||
<string name="share_intent_parsing_failed">Fehlgeschlagene Bearbeitung der Freigabeanfrage.</string>
|
||||
<string name="listdir_null_error_msg">Zugriff auf dieses Verzeichnis nicht möglich</string>
|
||||
<string name="fingerprint_save_checkbox_text">Passwort-Hash mit Fingerabdruck speichern</string>
|
||||
<string name="fingerprint_instruction">Bitte berühren Sie den Fingerabdrucksensor</string>
|
||||
<string name="illegal_block_size_exception">IllegalBlockSizeException</string>
|
||||
<string name="illegal_block_size_exception_msg">Dies kann passieren, wenn Sie einen neuen Fingerabdruck hinzugefügt haben. Das Zurücksetzen des Hash-Speichers kann dieses Problem lösen.</string>
|
||||
<string name="reset_hash_storage">Reset hash storage</string>
|
||||
<string name="MAC_verification_failed">Signatur/MAC-Verifizierung fehlgeschlagen. Entweder Android KeyStore oder der gespeicherte Hash wurde verändert. Das Zurücksetzen des Hash-Speichers kann dieses Problem lösen.</string>
|
||||
<string name="hash_storage_reset">Hash-Speicher erfolgreich zurückgesetzt</string>
|
||||
<string name="encrypt_action_description">Kennwort-Hash verschlüsseln und speichern.</string>
|
||||
<string name="decrypt_action_description">Passwort-Hash entschlüsseln.</string>
|
||||
<string name="title_activity_settings">DroidFS Einstellungen</string>
|
||||
<string name="explorer">Explorer</string>
|
||||
<string name="settings_title_sort_order">Standard-Sortierreihenfolge</string>
|
||||
<string name="usf_decrypt">Exportieren/Entschlüsseln von Dateien zulassen</string>
|
||||
<string name="usf_share">Freigeben von Dateien über das Android-Freigabemenü erlauben</string>
|
||||
<string name="usf_open">Erlaubt das Öffnen von Dateien mit anderen Anwendungen</string>
|
||||
<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="unsafe_features">Unsichere Funktionen</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>
|
||||
<string name="open_as">Öffnen als</string>
|
||||
<string name="image">Bild</string>
|
||||
<string name="video">Video</string>
|
||||
<string name="audio">Audio</string>
|
||||
<string name="playing_failed">Die Datei konnte nicht abgespielt werden: %s</string>
|
||||
<string name="text">Text</string>
|
||||
<string name="save_failed">Speichern fehlgeschlagen</string>
|
||||
<string name="file_saved">Datei gespeichert.</string>
|
||||
<string name="ask_save">Die Datei enthält ungespeicherte Änderungen. Möchten Sie diese vor dem Beenden speichern?</string>
|
||||
<string name="save">Speichern</string>
|
||||
<string name="discard">Verwerfen</string>
|
||||
<string name="word_wrap">Wortumbruch</string>
|
||||
<string name="outofmemoryerror_msg">OutOfMemoryError: Diese Datei ist zu groß, um in den Speicher geladen zu werden.</string>
|
||||
<string name="new_file">Neue Datei erstellen</string>
|
||||
<string name="enter_file_name">Dateiname:</string>
|
||||
<string name="file_creation_failed">Die Datei konnte nicht erstellt werden.</string>
|
||||
<string name="loading">Laden…</string>
|
||||
<string name="loading_msg_create">Erzeuge Datenträger…</string>
|
||||
<string name="loading_msg_change_password">Kennwort ändern…</string>
|
||||
<string name="loading_msg_open">Datenträger öffnen…</string>
|
||||
<string name="loading_msg_export">Dateien exportieren…</string>
|
||||
<string name="query_cursor_null_error_msg">Zugriff auf diese Datei nicht möglich</string>
|
||||
<string name="about">Über uns</string>
|
||||
<string name="github">GitHub</string>
|
||||
<string name="github_summary">Das DroidFS-Repository auf GitHub. Quellcode, Dokumentation, Bugtracker…</string>
|
||||
<string name="gitea">Gitea</string>
|
||||
<string name="gitea_summary">Das DroidFS-Repository auf der Chapril Gitea-Instanz. Im Gegensatz zu GitHub ist Gitea vollständig freie Software und wird selbst gehostet. Quellcode, Dokumentation, Bugtracker…</string>
|
||||
<string name="share">Share</string>
|
||||
<string name="decrypt_files">Exportieren/Entschlüsseln</string>
|
||||
<string name="copy_failed">Kopie von %s fehlgeschlagen.</string>
|
||||
<string name="copy_success">Kopie erfolgreich erstellt.</string>
|
||||
<string name="add">Hinzufügen</string>
|
||||
<string name="camera">Kamera</string>
|
||||
<string name="picture_save_success">Bild gespeichert in %s</string>
|
||||
<string name="picture_save_failed">Das Bild konnte nicht gespeichert werden.</string>
|
||||
<string name="video_save_success">Video gespeichert unter %s</string>
|
||||
<string name="file_overwrite_question">%s existiert bereits, wollen Sie es überschreiben?</string>
|
||||
<string name="dir_overwrite_question">%s existiert bereits, soll der Inhalt zusammengeführt werden?</string>
|
||||
<string name="enter_new_name">Neuen Namen eingeben</string>
|
||||
<string name="copy_menu_title">Kopieren</string>
|
||||
<string name="move_failed">Verschiebung von %s fehlgeschlagen.</string>
|
||||
<string name="move_success">Verschiebung erfolgreich.</string>
|
||||
<string name="enter_timer_duration">Eingabe der Timer-Dauer (in s)</string>
|
||||
<string name="path_error">Der ausgewählte Pfad konnte nicht abgerufen werden.</string>
|
||||
<string name="create_cant_write_error_msg">DroidFS hat keinen Schreibzugriff auf diesen Pfad. Bitte versuchen Sie einen anderen Ort.</string>
|
||||
<string name="add_cant_write_warning">DroidFS hat keinen Schreibzugriff auf diesen Pfad. Fügen Sie ein Volume mit nur Lesezugriff hinzu.</string>
|
||||
<string name="sdcard_error_header">DroidFS kann nur auf entfernbare SD-Karten schreiben unter:</string>
|
||||
<string name="sdcard_error_add_footer">Datenträger mit Nur-Lese-Zugriff hinzufügen.</string>
|
||||
<string name="sdcard_error_create_footer">Bitte verwenden Sie ein Unterverzeichnis dieses Pfades oder den internen Speicher.</string>
|
||||
<string name="slideshow_stopped">Slideshow gestoppt</string>
|
||||
<string name="slideshow_started">Diashow gestartet</string>
|
||||
<string name="ask_save_img_rotated">Das Bild wurde gedreht. Möchten Sie diese Änderungen speichern und das ursprüngliche Bild überschreiben?</string>
|
||||
<string name="image_saved_successfully">Bildänderungen erfolgreich gespeichert.</string>
|
||||
<string name="bitmap_compress_failed">Komprimierung der Bitmap fehlgeschlagen.</string>
|
||||
<string name="file_write_failed">Fehler beim Schreiben der Datei.</string>
|
||||
<string name="error_not_a_volume">Verschlüsselter Datenträger nicht erkannt. Bitte überprüfen Sie den gewählten Pfad.</string>
|
||||
<string name="version">Version</string>
|
||||
<string name="error_cipher_null">Fehler: Chiffre ist null</string>
|
||||
<string name="key_permanently_invalidated_exception">KeyPermanentlyInvalidatedException</string>
|
||||
<string name="key_permanently_invalidated_exception_msg">Es sieht so aus, als hätten Sie einen neuen Fingerabdruck hinzugefügt. Der Hash der gespeicherten Passwörter ist unbrauchbar geworden.</string>
|
||||
<string name="usf_read_doc">Sie sollten es sorgfältig lesen, bevor Sie eine dieser Optionen aktivieren.</string>
|
||||
<string name="usf_doc">Dokumentation der unsicheren Funktionen</string>
|
||||
<string name="error_retrieving_filename">Dateiname für URI nicht abrufbar: %s</string>
|
||||
<string name="hidden_volume">Versteckter Datenträger</string>
|
||||
<string name="error_slash_in_name">Datenträgername darf keine Schrägstriche enthalten</string>
|
||||
<string name="hidden_volume_warning">Versteckte Volumes werden im internen Speicher der App gespeichert. Andere Apps können diese Volumes ohne Root-Zugriff nicht sehen. Wenn du jedoch DroidFS deinstallierst oder die Daten der App löschst, sind alle versteckten Volumes VERLOREN. Stellen Sie sicher, dass Sie Backups machen!</string>
|
||||
<string name="camera_perm_needed">Die Kameraerlaubnis wird benötigt, um Fotos zu machen.</string>
|
||||
<string name="choose_resolution">Wählen Sie eine Auflösung</string>
|
||||
<string name="file_operations">Dateioperationen</string>
|
||||
<string name="file_op_copy_msg">Kopieren von Dateien…</string>
|
||||
<string name="file_op_import_msg">Dateien importieren…</string>
|
||||
<string name="file_op_export_msg">Dateien exportieren…</string>
|
||||
<string name="file_op_move_msg">Dateien verschieben…</string>
|
||||
<string name="file_op_wiping_msg">Dateien löschen…</string>
|
||||
<string name="folders_first">Ordner zuerst</string>
|
||||
<string name="folders_first_summary">Ordner am Anfang der Liste anzeigen</string>
|
||||
<string name="auto_fit_title">Bildschirm des Videoplayers wird automatisch gedreht</string>
|
||||
<string name="auto_fit_summary">Bildschirm automatisch drehen, um sich den Videoabmessungen anzupassen</string>
|
||||
<string name="open_tree_failed">Kein Datei-Explorer gefunden. Bitte installieren Sie einen und versuchen Sie es erneut.</string>
|
||||
<string name="close_volume">Laufwerk schließen</string>
|
||||
<string name="sort_by">Sortieren nach</string>
|
||||
<string name="cut">Ausschneiden</string>
|
||||
<string name="map_folders">Ordner zuordnen</string>
|
||||
<string name="map_folders_summary">Ordner rekursiv zuordnen, um ihre Größe zu berechnen (Sie sollten dies beim Öffnen großer Volumes deaktivieren)</string>
|
||||
<string name="camera_optimization">Kameraoptimierung</string>
|
||||
<string name="maximize_quality">Qualität maximieren</string>
|
||||
<string name="minimize_latency">Latenzzeit minimieren</string>
|
||||
<string name="auto">Auto</string>
|
||||
<string name="encryption_cipher_label">Verschlüsselungs-Chiffre:</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="thumbnails">Vorschaubilder</string>
|
||||
<string name="thumbnails_summary">Miniaturansichten von Bildern und Videos anzeigen</string>
|
||||
<string name="seek_seconds_forward">+%d Sekunden</string>
|
||||
<string name="seek_seconds_backward">-%d Sekunden</string>
|
||||
<string name="add_volume">Datenträger hinzufügen</string>
|
||||
<string name="pick_directory">Verzeichnis auswählen</string>
|
||||
<string name="volume_alread_saved">Volume bereits gespeichert</string>
|
||||
<string name="open_dialog_title">Öffnen von %s:</string>
|
||||
<string name="remove">Entfernen</string>
|
||||
<string name="settings">Einstellungen</string>
|
||||
<string name="select_all">Alle auswählen</string>
|
||||
<string name="remove_fingerprint">Fingerabdruck entfernen</string>
|
||||
<string name="unrecoverable_key_exception_msg">%s. Kann den Verschlüsselungsschlüssel nicht laden.</string>
|
||||
<string name="unrecoverable_key_exception">UnrecoverableKeyException</string>
|
||||
<string name="delete_hidden_volume_question">%s ist versteckt, wollen Sie nur den Pfad des Volumes vergessen oder auch seinen gesamten INHALT LÖSCHEN?</string>
|
||||
<string name="forget_only">Nur vergessen</string>
|
||||
<string name="delete_volume">Datenträger löschen</string>
|
||||
<string name="hidden_volume_description">Das Volume im internen DroidFS-Speicher speichern</string>
|
||||
<string name="error_is_file">Fehler: Datei existiert</string>
|
||||
<string name="volume_path_label">Wählen Sie den Pfad zum Volume:</string>
|
||||
<string name="volume_name_label">Geben Sie den Namen des Datenträgers ein:</string>
|
||||
<string name="volume_path_hint">Datenträgerpfad</string>
|
||||
<string name="volume_name_hint">Datenträgername</string>
|
||||
<string name="password_label">Geben Sie das Volume-Passwort ein:</string>
|
||||
<string name="password_confirmation_label">Wiederholen Sie das Passwort:</string>
|
||||
<string name="password_confirmation_hint">Passwort (Bestätigung)</string>
|
||||
<string name="password_hash_saved">Passwort-Hash gespeichert</string>
|
||||
<string name="no_volumes_text">Kein Volumen gespeichert, fügen Sie eines hinzu, indem Sie auf die Schaltfläche + klicken</string>
|
||||
<string name="fingerprint_error_msg">Fingerabdruck-Authentifizierung kann nicht verwendet werden: %s.</string>
|
||||
<string name="keyguard_not_secure">Keyguard nicht sicher</string>
|
||||
<string name="no_hardware">Keine geeignete Hardware gefunden</string>
|
||||
<string name="hardware_unavailable">Hardware nicht verfügbar</string>
|
||||
<string name="no_fingerprint">kein registrierter Fingerabdruck</string>
|
||||
<string name="unknown_error">unbekannter Fehler</string>
|
||||
<string name="biometric_error">Biometrischer Fehler: %s</string>
|
||||
<string name="apply_to_all">Diese Auswahl auf alle versteckten Datenträger anwenden</string>
|
||||
<string name="select_volume">Datenträger auswählen</string>
|
||||
<string name="current_password_label">Geben Sie das aktuelle Volume-Passwort ein:</string>
|
||||
<string name="current_password_hint">Aktuelles Passwort</string>
|
||||
<string name="new_password_label">Geben Sie das neue Volume-Passwort ein:</string>
|
||||
<string name="new_password_hint">Neues Passwort</string>
|
||||
<string name="new_password_confirmation_label">Wiederholen Sie das neue Passwort:</string>
|
||||
<string name="error_marshmallow_required">Diese Funktion ist nur unter Android 6.0 (Marshmallow) oder höher verfügbar.</string>
|
||||
<string name="copy_hidden_volume">Kopieren in gemeinsamen Speicher</string>
|
||||
<string name="copy_external_volume">Eine versteckte Kopie erstellen</string>
|
||||
<string name="copy_volume_notification">Datenträger kopieren…</string>
|
||||
<string name="hidden_volume_already_exists">Ein versteckter Datenträger mit dem gleichen Namen existiert bereits.</string>
|
||||
<string name="pdf_document">PDF-Dokument</string>
|
||||
<string name="thumbnail_max_size">Maximale Größe für Miniaturansichten</string>
|
||||
<string name="thumbnail_max_size_summary">Maximale Dateigröße, für die eine Vorschau geladen werden soll. Aktueller Wert: %s</string>
|
||||
<string name="size_hint">Größe (in KB)</string>
|
||||
<string name="invalid_number">Ungültige Zahl</string>
|
||||
<string name="new_volume_name">Neuer Datenträgername:</string>
|
||||
<string name="volume_rename_failed">Datenträger konnte nicht umbenannt werden</string>
|
||||
<string name="switch_display_layout">Anzeigelayout umschalten</string>
|
||||
<string name="one_file">1 Datei</string>
|
||||
<string name="multiple_files">%d Dateien</string>
|
||||
<string name="one_folder">1 Ordner</string>
|
||||
<string name="multiple_folders">%d Ordner</string>
|
||||
<string name="default_open">Öffnen Sie diesen Datenträger beim Start der Anwendung</string>
|
||||
<string name="remove_default_open">Standardmäßig nicht öffnen</string>
|
||||
<string name="elements_selected">%d/%d ausgewählt</string>
|
||||
<string name="pin_passwords_title">Numerische Tastaturbelegung</string>
|
||||
<string name="pin_passwords_summary">Numerische Tastaturbelegung bei der Eingabe von Volume-Passwörtern verwenden</string>
|
||||
<string name="volume_type_label">Lautstärketyp:</string>
|
||||
<string name="gocryptfs">Gocryptfs</string>
|
||||
<string name="cryfs">CryFS</string>
|
||||
<string name="gocryptfs_disabled">Die Unterstützung von Gocryptfs wurde deaktiviert</string>
|
||||
<string name="cryfs_disabled">CryFS-Unterstützung wurde deaktiviert</string>
|
||||
<string name="file_op_delete_msg">Löschen von Dateien…</string>
|
||||
<string name="volume_type">(%s)</string>
|
||||
<string name="volume_type_read_only">(%s, schreibgeschützt)</string>
|
||||
<string name="volume_type_inaccessible">(%s, unzugänglich)</string>
|
||||
<string name="io_error">I/O Fehler.</string>
|
||||
<string name="use_fingerprint">Fingerabdruck anstelle des aktuellen Passworts verwenden</string>
|
||||
<string name="remember_volume">Volumen merken</string>
|
||||
<string name="open_volume">Laufwerk öffnen</string>
|
||||
<string name="choose_existing_volume">Bitte wählen Sie einen vorhandenen Datenträger</string>
|
||||
<string name="volume_unlocked">Volumen entsperrt</string>
|
||||
<string name="lock_volume">Datenträger sperren</string>
|
||||
<string name="lock">Sperren</string>
|
||||
<string name="ux">UX</string>
|
||||
<string name="theme_color">Farbschema </string>
|
||||
<string name="theme_color_summary">Farbschema der Anwendung ändern</string>
|
||||
<string name="black_theme">Dark Mode</string>
|
||||
<string name="password_fallback">Passwort anfordern</string>
|
||||
<string name="password_fallback_summary">Aufforderung zur Eingabe des Passworts, wenn die Authentifizierung per Fingerabdruck abgebrochen wird</string>
|
||||
<string name="unknown_error_code">Unbekannter Fehlercode: %d</string>
|
||||
<string name="config_load_error">Die Konfigurationsdatei kann nicht geladen werden. Stellen Sie sicher, dass der Datenträger zugänglich ist.</string>
|
||||
<string name="wrong_password">Die Konfigurationsdatei kann nicht entschlüsselt werden. Bitte überprüfen Sie Ihr Passwort.</string>
|
||||
<string name="filesystem_id_changed">Die Dateisystem-ID in der Konfigurationsdatei ist eine andere als beim letzten Öffnen dieses Datenträgers. Dies könnte bedeuten, dass ein Angreifer das Dateisystem durch ein anderes ersetzt hat.</string>
|
||||
<string name="inaccessible_base_dir">Das Volume existiert nicht oder ist unzugänglich.</string>
|
||||
<string name="task_failed">Die Aufgabe ist fehlgeschlagen: %s</string>
|
||||
<string name="usf_expose">Geöffnete Datenträger anzeigen</string>
|
||||
<string name="usf_expose_summary">Anderen Anwendungen das Durchsuchen offener Datenträger als Dokumentanbieter erlauben</string>
|
||||
<string name="usf_saf_write">Schreibzugriff gewähren</string>
|
||||
<string name="usf_saf_write_summary">Schreibzugriff beim Öffnen von Dateien mit anderen Anwendungen gewähren</string>
|
||||
<string name="saf">Storage Access Framework</string>
|
||||
<string name="tmp_export_failed">Export fehlgeschlagen: %s</string>
|
||||
<string name="export_failed_create">kann exportierte Datei nicht erstellen</string>
|
||||
<string name="export_failed_export">Fehler beim Exportieren der Datei</string>
|
||||
<string name="export_mem">Exportieren in den Arbeitsspeicher…</string>
|
||||
<string name="export_disk">Exportieren auf den Datenträger…</string>
|
||||
</resources>
|
@ -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="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="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="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,6 +74,7 @@
|
||||
<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>
|
||||
@ -241,7 +242,6 @@
|
||||
<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>
|
||||
@ -255,27 +255,5 @@
|
||||
<string name="theme_color_summary">Cambiar el color del tema de la aplicación</string>
|
||||
<string name="black_theme">Tema en negro</string>
|
||||
<string name="password_fallback">Reintroducir contraseña</string>
|
||||
<string name="password_fallback_summary">Solicitar contraseña cuando se cancela la autenticación por huella dactilar</string>
|
||||
<string name="unknown_error_code">Código de error desconocido: %d</string>
|
||||
<string name="config_load_error">No se puede cargar el archivo de configuración. Asegúrese de que el volumen es accesible.</string>
|
||||
<string name="wrong_password">No se puede descifrar el archivo de configuración. Por favor, comprueba tu contraseña.</string>
|
||||
<string name="filesystem_id_changed">El id del sistema de ficheros en el fichero de configuración es diferente a la última vez que se abrió este volumen. Esto podría significar que un atacante sustituyó el sistema de archivos por otro diferente.</string>
|
||||
<string name="inaccessible_base_dir">El volumen no existe o es inaccesible.</string>
|
||||
<string name="task_failed">La tarea falló: %s</string>
|
||||
<string name="usf_expose">Exponer volúmenes abiertos</string>
|
||||
<string name="usf_expose_summary">Permitir que otras aplicaciones exploren volúmenes abiertos como fuentes de documentos</string>
|
||||
<string name="usf_saf_write">Conceder acceso de escritura</string>
|
||||
<string name="usf_saf_write_summary">Conceder acceso de escritura al abrir archivos con otras aplicaciones</string>
|
||||
<string name="saf">Marco de acceso al almacenamiento</string>
|
||||
<string name="tmp_export_failed">Falló la exportación: %s</string>
|
||||
<string name="export_failed_create">no se pudo crear el archivo exportado</string>
|
||||
<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>
|
||||
<string name="password_fallback_summary">Solicitar la contraseña cuando se cancela la autenticación por huella dactilar</string>
|
||||
</resources>
|
||||
|
@ -1,52 +0,0 @@
|
||||
<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>
|
@ -1,282 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">DroidFS</string>
|
||||
<string name="create_volume">צור אמצעי אחסון</string>
|
||||
<string name="open">פתח</string>
|
||||
<string name="create">צור</string>
|
||||
<string name="change_password">שנה סיסמא</string>
|
||||
<string name="password">סיסמא</string>
|
||||
<string name="import_files">ייבוא/להצפין קבצים</string>
|
||||
<string name="import_folder">ייבוא/להצפין תיקיות</string>
|
||||
<string name="discovering_files">סורק קבצים..</string>
|
||||
<string name="mkdir">צור תיקייה</string>
|
||||
<string name="dir_empty">ספריה ריקה</string>
|
||||
<string name="warning">אזהרה !</string>
|
||||
<string name="ask_lock_volume">האם אתה בטוח שברצונך לנעול אמצעי אחסון זה?</string>
|
||||
<string name="ok">בסדר</string>
|
||||
<string name="cancel">ביטול</string>
|
||||
<string name="enter_folder_name">שם תיקייה:</string>
|
||||
<string name="error">שגיאה</string>
|
||||
<string name="error_filename_empty">.נא להזין שם</string>
|
||||
<string name="error_mkdir">יצירת תיקייה נכשלה.</string>
|
||||
<string name="success_import">יובא בהצלחה!</string>
|
||||
<string name="success_import_msg">הקבצים שנבחרו יובאו בהצלחה.</string>
|
||||
<string name="import_failed">ייבוא של %s נכשל.</string>
|
||||
<string name="export_failed">ייצוא של %s נכשל.</string>
|
||||
<string name="success_export">יוצא בהצלחה!</string>
|
||||
<string name="remove_failed">המחיקה של %s נכשלה.</string>
|
||||
<string name="passwords_mismatch">סיסמאות לא תואמות</string>
|
||||
<string name="dir_not_empty">הספריה שנבחרה אינה ריקה</string>
|
||||
<string name="create_volume_failed">יצירת אמצעי אחסון נכשלה</string>
|
||||
<string name="open_volume_failed">פתיחה נכשלה</string>
|
||||
<string name="share_chooser">שתף קובץ</string>
|
||||
<string name="storage_perm_denied">הרשאת אחסון נדחתה</string>
|
||||
<string name="storage_perm_denied_msg">DroidFS לא יכול לעבוד ללא הרשאת אחסון.</string>
|
||||
<string name="get_size_failed">אחזור גודל הקובץ נכשל.</string>
|
||||
<string name="parent_folder">תיקיית אב</string>
|
||||
<string name="empty_volume_path">אנא הזן נתיב לאמצעי אחסון</string>
|
||||
<string name="empty_volume_name">הזן שם לאמצעי אחסון</string>
|
||||
<string name="external_open">פתח עם אפליקציה חיצונית</string>
|
||||
<string name="single_delete_confirm">האם אתה בטוח שברצונך למחוק %s ?</string>
|
||||
<string name="multiple_delete_confirm">האם אתה בטוח שברצנך למחוק פריטים אלה? %s </string>
|
||||
<string name="location">מיקום: %s</string>
|
||||
<string name="total_size">משקל כולל: %s</string>
|
||||
<string name="import_from_other_volume">ייבא מאמצעי אחסון אחר</string>
|
||||
<string name="read_file_failed">נכשל לפתוח את הקובץ הזה.</string>
|
||||
<string name="volume">אמצעי אחסון: %s</string>
|
||||
<string name="yes">כן</string>
|
||||
<string name="no">לא</string>
|
||||
<string name="ask_for_wipe">האם ברצונך למחוק את הקבצים המקוריים?</string>
|
||||
<string name="wipe_failed">המחיקה נכשלה: %s</string>
|
||||
<string name="wipe_successful">הקבצים נמחקו בהצלחה!</string>
|
||||
<string name="rename">שנה שם</string>
|
||||
<string name="rename_title">שם חדש:</string>
|
||||
<string name="rename_failed">שינוי השם נכשל</string>
|
||||
<string name="sort_order">מיין לפי:</string>
|
||||
<string name="change_password_failed">הפעולה נכשלה. אנא בדוק את הסיסמה הישנה שלך.</string>
|
||||
<string name="share_menu_label">הצפן עם DroidFS</string>
|
||||
<string name="share_intent_parsing_failed">בקשת השיתוף נכשלה</string>
|
||||
<string name="listdir_null_error_msg">אין אפשרות לגשת לספריה זו</string>
|
||||
<string name="fingerprint_save_checkbox_text">שמור גיבוב של הסיסמא באמצעות טביעת אצבע</string>
|
||||
<string name="fingerprint_instruction">אנא גע בחיישן טביעת האצבע</string>
|
||||
<string name="illegal_block_size_exception">הרחבת גודל בלוק לא חוקית</string>
|
||||
<string name="illegal_block_size_exception_msg">זה יכול לקרות אם הוספת טביעת אצבע חדשה, איפוס אחסון מגובב יכול לפתור את זה.</string>
|
||||
<string name="reset_hash_storage">אפס אחסון מגובב</string>
|
||||
<string name="MAC_verification_failed">חתימה/אימות מאק נכשל. חנות המפתחות של אנדרואיד או הקוד המגובב שנשמר השתנו. איפוס אחסון מגובב יכול לפתור את זה.</string>
|
||||
<string name="hash_storage_reset">אפס אחסון מגובב</string>
|
||||
<string name="encrypt_action_description">מצפין ושומר גיבוב של הסיסמא</string>
|
||||
<string name="decrypt_action_description">מפענח גיבוב סיסמא.</string>
|
||||
<string name="title_activity_settings">DroidFS הגדרות</string>
|
||||
<string name="explorer">גלה</string>
|
||||
<string name="settings_title_sort_order">מיון ברירת המחדל</string>
|
||||
<string name="usf_decrypt">אפשר ייצוא קבצים/פענוח</string>
|
||||
<string name="usf_share"> אפשר שיתוף קבצים דרך תפריט השיתוף של אנדרואיד </string>
|
||||
<string name="usf_open">אפשר פתיחת קבצים עם יישומים אחרים </string>
|
||||
<string name="usf_screenshot">אפשר צילומי מסך</string>
|
||||
<string name="usf_fingerprint">אפשר שימרת גיבוב של הסיסמא באמצעות טביעת אצבע</string>
|
||||
<string name="usf_volume_management">הגדרת אמצעי אחסון</string>
|
||||
<string name="usf_keep_open">השאר את האמצעי אחסון פתוח כשהאפליקציה רצה ברקע</string>
|
||||
<string name="unsafe_features">פיצרים לא בטוחים</string>
|
||||
<string name="manage_unsafe_features">נהל פיצרים לא בטוחים</string>
|
||||
<string name="manage_unsafe_features_summary">אפשר/מנע פיצרים לא בטוחים</string>
|
||||
<string name="usf_home_warning_msg">DroidFS מנסה להיות מאובטח ככל האפשר,עם זאת אבטחה כרוכה לעיתים קרובת בחוסר נוחות. זה הסיבה ש DroidFS מציע פיצרים לא בטוחים שאתה יכול להפעיל /להשבית בהתאם לצרכים שלך .\n\n אזהרה: פיצרים אלה יכולים להיות לא בטוחים. אל תשתמש בהם אלא אם כן אתה יודע בידיוק מה אתה עושה. מומלץ מאוד לקרוא את הקובץ דוקמנטציה לפני שמפעילים אותם.</string>
|
||||
<string name="see_unsafe_features">הראה פיצרים לא בטוחים</string>
|
||||
<string name="open_as">פתח כ</string>
|
||||
<string name="image">תמונה</string>
|
||||
<string name="video">וידאו</string>
|
||||
<string name="audio">שמע</string>
|
||||
<string name="playing_failed">נכשל לנגן את הקובץ הזה: %s</string>
|
||||
<string name="text">טקסט</string>
|
||||
<string name="save_failed">השמירה נכשלה</string>
|
||||
<string name="file_saved">קובץ נשמר!</string>
|
||||
<string name="ask_save">הקובץ מכיל שינויים שלא נשמרו. האם ברצונך לשמור אותם לפני היציאה?</string>
|
||||
<string name="save">שמור</string>
|
||||
<string name="discard">אל תשמור</string>
|
||||
<string name="word_wrap">התחל שורה משמאל לשפה האנגלית</string>
|
||||
<string name="outofmemoryerror_msg">OutOfMemoryError: הקובץ גדול מדי כדי לטעון אותו בזכרון.</string>
|
||||
<string name="new_file">צור קובץ חדש</string>
|
||||
<string name="enter_file_name">שם הקובץ:</string>
|
||||
<string name="file_creation_failed">נכשל ליצור קובץ חדש.</string>
|
||||
<string name="loading">טוען</string>
|
||||
<string name="loading_msg_create">יוצר אמצעי אחסון…</string>
|
||||
<string name="loading_msg_change_password">משנה סיסמא…</string>
|
||||
<string name="loading_msg_open">פותח אמצעי אחסון…</string>
|
||||
<string name="loading_msg_export">מייצא קבצים…</string>
|
||||
<string name="query_cursor_null_error_msg">לא הצלחתי לגשת לקובץ הזה.</string>
|
||||
<string name="about">אודות</string>
|
||||
<string name="github">GitHub</string>
|
||||
<string name="github_summary">מאגר DroidFS ב- GitHub. קוד מקור, תיעוד, מעקב אחר באגים…</string>
|
||||
<string name="gitea">Gitea</string>
|
||||
<string name="gitea_summary">מאגר DroidFS בChapril Gitea. שלא כמו GitHub, Gitea היא תוכנה חינמית לחלוטין ומתארחת בעצמה. קוד מקור, תיעוד, מעקב אחר באגים…</string>
|
||||
<string name="share">שתף</string>
|
||||
<string name="decrypt_files">ייצא/פענח</string>
|
||||
<string name="copy_failed">העתקת %s נכשלה.</string>
|
||||
<string name="copy_success">העתקה בוצע בהצלחה.</string>
|
||||
<string name="add">הוסף</string>
|
||||
<string name="camera">מצלמה</string>
|
||||
<string name="picture_save_success">התמונה נשמרה ל %s</string>
|
||||
<string name="picture_save_failed">נכשל לשמור את התמונה הזאת.</string>
|
||||
<string name="video_save_success">הסרטון נשמר ל %s</string>
|
||||
<string name="file_overwrite_question">%s כבר קיים, האם ברצונך להחליף אותו?</string>
|
||||
<string name="dir_overwrite_question">%s כבר קיים, האם ברצונך למזג את התוכן שלו ?</string>
|
||||
<string name="enter_new_name">הזן שם חדש</string>
|
||||
<string name="copy_menu_title">העתק</string>
|
||||
<string name="move_failed">העברת %s נכשלה.</string>
|
||||
<string name="move_success">העברה בוצעה בהצלחה!</string>
|
||||
<string name="enter_timer_duration">הזן את משך זמן הטיימר (בשניות)</string>
|
||||
<string name="path_error">לא הצלחתי לגשת לנתיב הזה.</string>
|
||||
<string name="create_cant_write_error_msg">לDroidFS אין הרשאת כתיבה במיקום הזה, תנסה מיקום אחר</string>
|
||||
<string name="add_cant_write_warning">ל- DroidFS אין הרשאת כתיבה לנתיב זה. מוסיף אמצעי אחסון עם הרשאת קריאה בלבד.</string>
|
||||
<string name="sdcard_error_header">DroidFS יכול לכתוב רק לכרטיסי SD נשלפים תחת:</string>
|
||||
<string name="sdcard_error_add_footer">מוסיף אמצעי אחסון עם הרשאת קריאה בלבד.</string>
|
||||
<string name="sdcard_error_create_footer">אנא השתמש בספריית משנה של נתיב זה או באחסון פנימי.</string>
|
||||
<string name="slideshow_stopped">הצגת השקופיות הופסקה</string>
|
||||
<string name="slideshow_started">התחל מצגת</string>
|
||||
<string name="ask_save_img_rotated">התמונה סובבה. האם ברצונך לשמור את השינויים הללו ולדרוס את התמונה המקורית?</string>
|
||||
<string name="image_saved_successfully">השינויים בתמונה נשמרו בהצלחה.</string>
|
||||
<string name="bitmap_compress_failed">דחיסת מפת הסיביות נכשלה.</string>
|
||||
<string name="file_write_failed">כתיבת הקובץ נכשלה.</string>
|
||||
<string name="error_not_a_volume">אמצעי אחסון מוצפן לא זוהה. אנא בדוק את הנתיב שנבחר.</string>
|
||||
<string name="version">גרסא</string>
|
||||
<string name="error_cipher_null">שגיאה: הסיסמא ריקה.</string>
|
||||
<string name="key_permanently_invalidated_exception">KeyPermanentlyInvalidatedException</string>
|
||||
<string name="key_permanently_invalidated_exception_msg">נראה שהוספת טביעת אצבע חדשה. הגיבוב של סיסמאות שמורות הפך לבלתי שמיש.</string>
|
||||
<string name="usf_read_doc">עליך לקרוא אותו בעיון לפני הפעלת כל אחת מהאפשרויות הללו.</string>
|
||||
<string name="usf_doc">דוקמנטציה של פיצרים לא בטוחים</string>
|
||||
<string name="error_retrieving_filename">לא ניתן לאחזר את שם הקובץ עבור הקישור: %s</string>
|
||||
<string name="hidden_volume">אמצעי אחסון נסתר</string>
|
||||
<string name="error_slash_in_name">שם האמצעי אחסון לא יכול להכיל סלאשים</string>
|
||||
<string name="hidden_volume_warning">אמצעי אחסון נסתרים מאוחסנים באחסון הפנימי של האפליקציה. אפליקציות אחרות לא יכולות לראות את אמצעי האחסון האלה ללא גישת שורש. עם זאת, אם תסיר את ההתקנה של DroidFS או תנקה את נתוני האפליקציה, כל אמצעי האחסון הנסתרים שלך יאבדו. הקפד לבצע גיבויים!</string>
|
||||
<string name="camera_perm_needed">הרשאת מצלמה נדרשת כדי לצלם תמונות</string>
|
||||
<string name="choose_resolution">בחר רזולוציה</string>
|
||||
<string name="file_operations">פעולות קובץ</string>
|
||||
<string name="file_op_copy_msg">מעתיק קבצים…</string>
|
||||
<string name="file_op_import_msg">מייבא קבצים…</string>
|
||||
<string name="file_op_export_msg">מייצא קבצים…</string>
|
||||
<string name="file_op_move_msg">מעביר קבצים…</string>
|
||||
<string name="file_op_wiping_msg">מוחק קבצים…</string>
|
||||
<string name="folders_first">תיקיות קודם</string>
|
||||
<string name="folders_first_summary">הראה תיקיות בתחילת הרשימה</string>
|
||||
<string name="auto_fit_title">סיבוב אוטומטי של מסך נגן מדיה</string>
|
||||
<string name="auto_fit_summary">סובב אוטומטית את המסך כך שיתאים למידות הווידאו</string>
|
||||
<string name="open_tree_failed">לא נמצא סייר קבצים. אנא התקן אחד ונסה שוב.</string>
|
||||
<string name="close_volume">סגור אמצעי אחסון</string>
|
||||
<string name="sort_by">מיין לפי</string>
|
||||
<string name="cut">גזור</string>
|
||||
<string name="map_folders">מפה תיקיות</string>
|
||||
<string name="map_folders_summary">מפה באופן רקורסיבי תיקיות כדי לחשב את הגדלים שלהן (עליך להשבית זאת בעת פתיחת אמצעי אחסון גדולים)</string>
|
||||
<string name="camera_optimization">אופטימיזציה של מצלמה</string>
|
||||
<string name="maximize_quality">איכות מקסימלית</string>
|
||||
<string name="minimize_latency">איכות מינימלית</string>
|
||||
<string name="auto">אוטומטי</string>
|
||||
<string name="encryption_cipher_label">הצפנת צופן:</string>
|
||||
<string name="theme">ערכת נושא</string>
|
||||
<string name="thumbnails">תמונות ממוזערות</string>
|
||||
<string name="thumbnails_summary">הצג תמונות וסרטונים ממוזערים</string>
|
||||
<string name="seek_seconds_forward">+%d שניות</string>
|
||||
<string name="seek_seconds_backward">-%d שניות</string>
|
||||
<string name="add_volume">הוסף אמצעי אחסון</string>
|
||||
<string name="pick_directory">בחר ספרייה</string>
|
||||
<string name="volume_alread_saved">אמצעי אחסון כבר נשמר</string>
|
||||
<string name="open_dialog_title">פותח %s:</string>
|
||||
<string name="remove">הסר</string>
|
||||
<string name="settings">הגדרות</string>
|
||||
<string name="select_all">בחר הכל</string>
|
||||
<string name="remove_fingerprint">הסר טביעת אצבע</string>
|
||||
<string name="unrecoverable_key_exception_msg">%s. לא מצליח לטעון מפתח הצפנה</string>
|
||||
<string name="unrecoverable_key_exception">UnrecoverableKeyException</string>
|
||||
<string name="delete_hidden_volume_question">%s מוסתר, האם אתה רק רוצה לשכוח את הנתיב של האמצעי אחסון או גם למחוק את כל התוכן שלו?</string>
|
||||
<string name="forget_only">שכח בלבד</string>
|
||||
<string name="delete_volume">מחק אמצעי אחסון</string>
|
||||
<string name="hidden_volume_description">אחסן את אמצעי האחסון באחסון הפנימי של DroidFS</string>
|
||||
<string name="error_is_file">שגיאה: קובץ בשם זה כבר קיים</string>
|
||||
<string name="volume_path_label">הזן נתיב לאמצעי אחסון:</string>
|
||||
<string name="volume_name_label">הזן שם לאמצעי אחסון:</string>
|
||||
<string name="volume_path_hint">נתיב אמצעי אחסון</string>
|
||||
<string name="volume_name_hint">שם אמצעי אחסון</string>
|
||||
<string name="password_label">הזן את הסיסמא של האמצעי אחסון:</string>
|
||||
<string name="password_confirmation_label">חזור שנית על הסיסמא:</string>
|
||||
<string name="password_confirmation_hint">אישור סיסמא</string>
|
||||
<string name="password_hash_saved">הגיבוב של הסיסמא נשמר</string>
|
||||
<string name="no_volumes_text">אין אמצעי אחסון שמורים, הוסף ע"י לחציה על הסימן +</string>
|
||||
<string name="fingerprint_error_msg">לא ניתן להשתמש באימות טביעת אצבע: %s.</string>
|
||||
<string name="keyguard_not_secure">מגן מקשים לא מאובטח</string>
|
||||
<string name="no_hardware">לא נמצאה חומרה מתאימה</string>
|
||||
<string name="hardware_unavailable">החומרה אינה זמינה</string>
|
||||
<string name="no_fingerprint">אין טביעת אצבע שמורה</string>
|
||||
<string name="unknown_error">שגיאה לא ידועה</string>
|
||||
<string name="biometric_error">שגיאה ביומטרית: %s</string>
|
||||
<string name="apply_to_all">החל את הבחירה הזו על כל אמצעי האחסון הנסתרים</string>
|
||||
<string name="select_volume">בחר אמצעי אחסון</string>
|
||||
<string name="current_password_label">הזן את סיסמאת אמצעי אחסון הנוכחי:</string>
|
||||
<string name="current_password_hint">סיסמא נוכחית</string>
|
||||
<string name="new_password_label">הזן את הסיסמא החדשה לאמצעי אחסון:</string>
|
||||
<string name="new_password_hint">סיסמא חדשה</string>
|
||||
<string name="new_password_confirmation_label">חזור שנית על הסיסמא החדשה:</string>
|
||||
<string name="error_marshmallow_required">הפיצר הזה זמין רק למשתמשי אנדרואיד 6 (מרשמלו) ומעלה</string>
|
||||
<string name="copy_hidden_volume">העתק לאחסון משותף</string>
|
||||
<string name="copy_external_volume">צור עותק נסתר</string>
|
||||
<string name="copy_volume_notification">מעתיק אמצעי אחסון…</string>
|
||||
<string name="hidden_volume_already_exists">אמצעי אחסון נסתר עם אותו שם כבר קיים.</string>
|
||||
<string name="pdf_document">מסמך PDF</string>
|
||||
<string name="thumbnail_max_size">גודל מקסימלי עבור תמונות ממוזערות</string>
|
||||
<string name="thumbnail_max_size_summary">גודל קובץ מקסימלי לטעינת תמונה ממוזערת. ערך נוכחי: %s</string>
|
||||
<string name="size_hint">גודל (בקילו בייט)</string>
|
||||
<string name="invalid_number">מספר לא תקין</string>
|
||||
<string name="new_volume_name">שם אמצעי אחסון חדש:</string>
|
||||
<string name="volume_rename_failed">שינוי שם אמצעי אחסון נכשל</string>
|
||||
<string name="switch_display_layout">החלף פריסת תצוגה</string>
|
||||
<string name="one_file">קובץ 1</string>
|
||||
<string name="multiple_files">%d קבצים</string>
|
||||
<string name="one_folder">1 תיקייה</string>
|
||||
<string name="multiple_folders">%d תיקיות</string>
|
||||
<string name="default_open">פתח אמצעי אחסון זה בעת הפעלת היישום</string>
|
||||
<string name="remove_default_open">אל תפתח כברירת מחדל</string>
|
||||
<string name="elements_selected">%d/%d נבחרו</string>
|
||||
<string name="pin_passwords_title">פריסת מקלדת מספרים</string>
|
||||
<string name="pin_passwords_summary">שימוש בפריסת מקלדת מספרים בעת הזנת סיסמאות אמצעי אחסון</string>
|
||||
<string name="volume_type_label">סוג אמצעי אחסון:</string>
|
||||
<string name="gocryptfs">Gocryptfs</string>
|
||||
<string name="cryfs">CryFS</string>
|
||||
<string name="gocryptfs_disabled">תמיכת Gocryptfs הושבתה</string>
|
||||
<string name="cryfs_disabled">תמיכת CryFS הושבתה</string>
|
||||
<string name="file_op_delete_msg">מוחק קבצים…</string>
|
||||
<string name="volume_type">(%s)</string>
|
||||
<string name="volume_type_read_only">(%s, קריאה בלבד)</string>
|
||||
<string name="volume_type_inaccessible">(%s, לא נגיש)</string>
|
||||
<string name="io_error">שגיאת קלט/פלט.</string>
|
||||
<string name="use_fingerprint">השתמש בטביעת אצבע במקום בסיסמה הנוכחית</string>
|
||||
<string name="remember_volume">זכור אמצעי אחסון</string>
|
||||
<string name="open_volume">פתח אמצעי אחסון</string>
|
||||
<string name="choose_existing_volume">אנא בחר אמצעי אחסון קיים</string>
|
||||
<string name="volume_unlocked">אמצעי אחסון פתוח</string>
|
||||
<string name="lock_volume">נעל אמצעי אחסון</string>
|
||||
<string name="lock">נעל</string>
|
||||
<string name="ux">חוויית משתמש</string>
|
||||
<string name="theme_color">צבע ערכת נושא</string>
|
||||
<string name="theme_color_summary">שנה את צבע הערכת נושא של היישום</string>
|
||||
<string name="black_theme">שחור</string>
|
||||
<string name="password_fallback">חזרה לסיסמא</string>
|
||||
<string name="password_fallback_summary">בקש סיסמה כאשר אימות טביעת האצבע מבוטל</string>
|
||||
<string name="unknown_error_code">קוד שגיאה לא ידוע: %d</string>
|
||||
<string name="config_load_error">לא ניתן לטעון את קובץ התצורה. ודא שיש גישה לאמצעי אחסון.</string>
|
||||
<string name="wrong_password">לא ניתן לפענח את קובץ התצורה. אנא בדוק את הסיסמה שלך.</string>
|
||||
<string name="filesystem_id_changed">מזהה מערכת הקבצים בקובץ התצורה שונה מהפעם האחרונה שפתחנו אמצעי אחסון זה. פירוש הדבר יכול להיות מפני שתוקף החליף את מערכת הקבצים במערכת אחרת.</string>
|
||||
<string name="inaccessible_base_dir">אמצעי האחסון לא קיים או שאינו נגיש.</string>
|
||||
<string name="task_failed">המשימה נכשלה: %s</string>
|
||||
<string name="usf_expose">חשוף אמצעי אחסון פתוחים</string>
|
||||
<string name="usf_expose_summary">אפשר ליישומים אחרים לעיין באמצעי אחסון פתוחים כספקי מסמכים</string>
|
||||
<string name="usf_saf_write">הענק הרשאת כתיבה</string>
|
||||
<string name="usf_saf_write_summary">הענק הרשאת כתיבה בעת פתיחת קבצים עם יישומים אחרים</string>
|
||||
<string name="saf">גישה לאחסון Framework</string>
|
||||
<string name="tmp_export_failed">הייצוא נכשל: %s</string>
|
||||
<string name="export_failed_create">לא הצלחתי ליצור קובץ ייצוא</string>
|
||||
<string name="export_failed_export">ייצוא הקובץ נכשל</string>
|
||||
<string name="export_mem">מייצא לזיכרון…</string>
|
||||
<string name="export_disk">מייצא לאחסון פנימי…</string>
|
||||
<string name="memfd_create_unsupported">הקרנל הנוכחי שלך לא תומכת ב-memfd_create(). תכונה זו דורשת גרסת קרנל מינימלי של %s.</string>
|
||||
<string name="export_method">שיטת ייצוא</string>
|
||||
<string name="export_method_summary">שיטת ייצוא קבצים. משמש לשיתוף, פתיחה חיצונית וגישה לקבצים חשופים.</string>
|
||||
<string name="debug">דיבאג</string>
|
||||
<string name="logcat_title">DroidFS Logcat</string>
|
||||
<string name="logcat_saved">Logcat נשמר</string>
|
||||
</resources>
|
@ -32,8 +32,8 @@
|
||||
<string name="storage_perm_denied_msg">DroidFS não pode funcionar sem permissão ao armazenamento.</string>
|
||||
<string name="get_size_failed">Falha na recuperação do tamanho do arquivo.</string>
|
||||
<string name="parent_folder">Pasta principal</string>
|
||||
<string name="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="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="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,6 +73,7 @@
|
||||
<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>
|
||||
|
@ -17,10 +17,4 @@
|
||||
<item>Фиолетовый</item>
|
||||
<item>Розовый</item>
|
||||
</string-array>
|
||||
|
||||
<string-array name="export_methods">
|
||||
<item>Автовыбор (зависит от доступной памяти)</item>
|
||||
<item>Временный файл в хранилище (надёжно, но могут остаться следы)</item>
|
||||
<item>Файл в памяти (безопаснее, но не всегда возможно)</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
|
@ -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="empty_volume_path">Введите путь тома</string>
|
||||
<string name="empty_volume_name">Введите название тома</string>
|
||||
<string name="enter_volume_path">Введите путь тома</string>
|
||||
<string name="enter_volume_name">Введите название тома</string>
|
||||
<string name="external_open">Открыть внешним приложением</string>
|
||||
<string name="single_delete_confirm">Удалить %s?</string>
|
||||
<string name="multiple_delete_confirm">Удалить %s элементов?</string>
|
||||
@ -71,6 +71,7 @@
|
||||
<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>
|
||||
@ -230,8 +231,7 @@
|
||||
<string name="gocryptfs_disabled">Поддержка Gocryptfs отключена</string>
|
||||
<string name="cryfs_disabled">Поддержка CryFS отключена</string>
|
||||
<string name="file_op_delete_msg">Удаление файлов…</string>
|
||||
<string name="volume_type_read_only">(%s, только чтение)</string>
|
||||
<string name="volume_type_inaccessible">(%s, недоступен)</string>
|
||||
<string name="volume_type_read_only">(%s, только для чтения)</string>
|
||||
<string name="io_error">Ошибка ввода/вывода.</string>
|
||||
<string name="use_fingerprint">Использовать отпечатка пальца вместо пароля</string>
|
||||
<string name="remember_volume">Запомнить том</string>
|
||||
@ -246,38 +246,4 @@
|
||||
<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="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">Журнал logcat DroidFS</string>
|
||||
<string name="logcat_saved">Журнал logcat сохранён</string>
|
||||
<string name="later">Позже</string>
|
||||
<string name="notification_denied_msg">Разрешение на отображение уведомления не получено. Фоновые операции с файлами не будут видны. Это можно изменить в настройках разрешений приложения.</string>
|
||||
<string name="keep_alive_notification_title">Служба поддержки работы</string>
|
||||
<string name="keep_alive_notification_text">Один или несколько томов остаются открытыми.</string>
|
||||
<string name="close_all">Закрыть все</string>
|
||||
<string name="usf_background">Отключить автоблокировку томов</string>
|
||||
<string name="usf_background_summary">Не блокировать тома автоматически при работе приложения в фоновом режиме</string>
|
||||
<string name="usf_keep_open">Держать тома открытыми</string>
|
||||
<string name="usf_keep_open_summary">Поддерживать постоянную работу приложения в фоновом режиме, чтобы тома оставались открытыми</string>
|
||||
<string name="gocryptfs_details">Быстрый, но не скрывает размеры файлов и структуру папок</string>
|
||||
<string name="cryfs_details">Медленнее, но защищает метаданные и предотвращает атаки замещения</string>
|
||||
<string name="or">или</string>
|
||||
<string name="enter_volume_path">Введите путь к тому</string>
|
||||
</resources>
|
||||
|
@ -1,52 +0,0 @@
|
||||
<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>
|
@ -1,281 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">DroidFS</string>
|
||||
<string name="create_volume">Birim oluştur</string>
|
||||
<string name="open">Aç</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">Açı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ı aç</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">Açı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">Açı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>
|
@ -1,20 +0,0 @@
|
||||
<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>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user