Compare commits

...

51 Commits

Author SHA1 Message Date
solokot 967d4551c5
Update Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-12 16:51:55 +01:00
Ali Beyaz b747d2822a
Add Turkish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-12 16:47:30 +01:00
CyanWolf e5652666d8
Update Spanish
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-11 18:12:17 +01:00
Muhmmad14333653 cda0e90b96
Update Arabic translations
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-11 18:08:54 +01:00
Matéo Duparc 6f43bc7417
Avoid being killed by SELinux when retrieving volume path 2024-02-11 17:55:24 +01:00
Matéo Duparc c26ab661c2
Logcat activity 2024-01-30 18:29:49 +01:00
Matéo Duparc 1c15f9fac8
Allow choosing export method 2024-01-28 15:44:53 +01:00
Matéo Duparc b4635dc2e0
Directory loading indicator 2024-01-13 23:19:22 +01:00
Matéo Duparc f4e47c1827
Allow directory creation on exposed volumes 2024-01-13 21:41:58 +01:00
Matéo Duparc 5474d6eea5
Add .opus & Update build config 2024-01-13 21:25:31 +01:00
Matéo Duparc 719faa31ee
Fix README 2023-10-15 17:09:48 +02:00
Matéo Duparc a41cde1c53
DroidFS v2.1.3 2023-09-28 19:36:55 +02:00
Matéo Duparc b503f134d5
Fix Intent.getParcelableExtra() crash on Android 13 2023-09-24 19:04:49 +02:00
Matéo Duparc 3ba774fda3
Add Version.toString() 2023-09-19 13:47:59 +02:00
Matéo Duparc b2154d319e
Repair corrupted database due to v2.1.1 2023-09-19 13:39:35 +02:00
Matéo Duparc 571a79cc1d
Really fix database upgrade 2023-09-19 11:41:01 +02:00
Matéo Duparc 891a581329
Update dependencies 2023-09-17 20:10:15 +02:00
Matéo Duparc f1a9c1383c
Fix database upgrade 2023-09-17 19:11:52 +02:00
Matéo Duparc ac71ad887d
Fix README 2023-09-10 21:39:28 +02:00
Matéo Duparc e1fe329f49
Add v2.1.0 changelog 2023-09-10 21:11:04 +02:00
Matéo Duparc dfff597ae5
DroidFS v2.1.0 2023-09-10 21:01:39 +02:00
Matéo Duparc bd429648b3
Update documentation 2023-09-10 21:01:04 +02:00
Matéo Duparc 71ff37b170
Fixes 2023-09-10 19:17:51 +02:00
Matéo Duparc 4afe56b13c
Migrate to AndroidX Media3 2023-09-10 19:12:17 +02:00
Matéo Duparc 217334a959
Fix es & de translations 2023-09-09 16:15:03 +02:00
CyanWolf 2666313676
Update Spanish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-09-08 21:32:46 +02:00
solokot 04e154a6d9
Updated Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-09-08 21:30:28 +02:00
Torsten Pfützenreuter d3760e2194
Added German translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-09-08 21:27:38 +02:00
Matéo Duparc d6c777875e
Fix VolumeProvider createDocument path 2023-09-08 21:22:20 +02:00
Matéo Duparc 8a18270b33
Update dependencies 2023-09-08 21:13:24 +02:00
Matéo Duparc 79db84f81d
Volume provider 2023-09-06 19:27:41 +02:00
Matéo Duparc 6d04349b2e
Prevent volume renaming when open 2023-09-06 19:27:04 +02:00
Matéo Duparc de0194a722
Always open volume after creation 2023-08-20 17:08:10 +02:00
Matéo Duparc 3127a15d9e
Fix ANR on recursive mapping 2023-08-20 16:42:40 +02:00
Matéo Duparc a08da2eacb
MemoryFileProvider 2023-08-20 14:56:46 +02:00
Matéo Duparc 1727170cb6
Limit the number of thumbnails loaded concurrently 2023-08-15 18:33:29 +02:00
Matéo Duparc 8776d2ee28
Add Support section in README 2023-08-15 18:06:39 +02:00
Matéo Duparc 5642e28b44
Fix TODO.md 2023-05-12 20:39:58 +02:00
Matéo Duparc 1b7e5904be
New screenshots 2023-05-12 20:26:45 +02:00
Matéo Duparc cb3fc3c70e
Re-ask only on wrong password 2023-05-11 21:58:55 +02:00
Matéo Duparc 393c458495
Offload file discovery for copy in coroutine 2023-05-11 21:24:29 +02:00
Matéo Duparc cdf98a7190
Handle cryfs inaccessible base dir 2023-05-11 00:02:05 +02:00
Matéo Duparc 2ae41f0f79
Improve file oprations coroutines 2023-05-10 23:41:29 +02:00
Muhmmad14333653 f85f9d1c44
Update arabic translation 2023-05-08 21:36:22 +02:00
Matéo Duparc 9fc981fee8
Fix rotation when rebinding camera use cases 2023-05-08 21:32:04 +02:00
Matéo Duparc ad19b9e645
Update dependencies 2023-05-08 20:58:54 +02:00
Matéo Duparc 87ffbc3cc1
Fix unsafe features doc link 2023-05-06 23:57:23 +02:00
Matéo Duparc b3a25e03e7
Improve video recording: fix freezes & ExoPlayer errors 2023-05-06 23:40:37 +02:00
Matéo Duparc 4c412be7dc
Best error messages when opening volumes 2023-05-03 14:14:40 +02:00
Matéo Duparc f4f3239bb1
Fix volume copying 2023-05-02 14:24:59 +02:00
Matéo Duparc 481558bd56
Add ecryptfs & shufflecake in TODO & Update README 2023-04-29 20:21:46 +02:00
120 changed files with 4134 additions and 1443 deletions

View File

@ -45,16 +45,16 @@ $ git clone --depth=1 https://git.ffmpeg.org/ffmpeg.git
If you want Gocryptfs support, you need to download OpenSSL:
```
$ cd ../libgocryptfs
$ wget https://www.openssl.org/source/openssl-1.1.1t.tar.gz
$ wget https://www.openssl.org/source/openssl-1.1.1w.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
$ wget https://www.openssl.org/source/openssl-1.1.1w.tar.gz.asc
$ gpg --verify openssl-1.1.1w.tar.gz.asc openssl-1.1.1w.tar.gz
```
Continue **ONLY** if the signature is **VALID**.
```
$ tar -xzf openssl-1.1.1t.tar.gz
$ tar -xzf openssl-1.1.1w.tar.gz
```
If you want CryFS support, initialize libcryfs:
```
@ -62,6 +62,14 @@ $ cd app/libcryfs
$ git submodule update --depth=1 --init
```
To be able to open PDF files internally, [pdf.js](https://github.com/mozilla/pdf.js) must be downloaded:
```
$ mkdir libpdfviewer/app/pdfjs-dist && cd libpdfviewer/app/pdfjs-dist
$ wget https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.8.162.tgz
$ tar xf pdfjs-dist-3.8.162.tgz package/build/pdf.min.js package/build/pdf.worker.min.js
$ mv package/build . && rm pdfjs-dist-3.8.162.tgz
```
# Build
Retrieve your Android NDK installation path, usually something like `/home/\<user\>/Android/SDK/ndk/\<NDK version\>`. Then, make it available in your shell:
```
@ -76,7 +84,7 @@ $ ./build.sh ffmpeg
This step is only required if you want Gocryptfs support.
```
$ cd app/libgocryptfs
$ OPENSSL_PATH="./openssl-1.1.1t" ./build.sh
$ OPENSSL_PATH="./openssl-1.1.1w" ./build.sh
```
## Compile APKs
Gradle build libgocryptfs and libcryfs by default.

20
DONATE.txt Normal file
View File

@ -0,0 +1,20 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
Here are the DroidFS donation addresses:
Monero (XMR):
86f82JEMd33WfapNZETukJW17eEa6RR4rW3wNEZ2CAZh228EYpDaar4DdDPUc4U3YT4CcFdW4c7462Uzx9Em2BB92Aj9fbT
Bitcoin (BTC):
bc1qeyvpy3tj4rr4my5f5wz9s8a4g4nh4l0kj4h6xy
-----BEGIN PGP SIGNATURE-----
iHUEARYIAB0WIQS2Tv6GzuHQVPCCFxGv44Q0SkXhOgUCZNuhaAAKCRCv44Q0SkXh
OqEUAP0d67oFlGp5IlBHwNI/p2KMHka3LzHdQTBQs40Jus3tVQEAsTZEy/sc6Nwp
C8mAXUTebijFgrlYYQkfVS0RBXHwggo=
=E6ia
-----END PGP SIGNATURE-----

View File

@ -2,14 +2,19 @@
An alternative way to use encrypted virtual filesystems on Android that uses its own internal file explorer instead of mounting volumes.
It currently supports [gocryptfs](https://github.com/rfjakob/gocryptfs) and [CryFS](https://github.com/cryfs/cryfs).
For mortals: An encrypted file manager for Android.
For mortals: Encrypted storage compatible with already existing softwares.
<p align="center">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" height="500">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" height="500">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" height="500">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" height="500">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" height="500">
</p>
# Support
The creator of DroidFS works as a freelance developer and privacy consultant. I am currently looking for new clients! If you are interested, take a look at the [website](https://arkensys.dedyn.io). Alternatively, you can directly support DroidFS by making a [donation](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/DONATE.txt).
Thank you so much ❤️.
# Disclaimer
DroidFS is provided "as is", without any warranty of any kind.
It shouldn't be considered as an absolute safe way to store files.
@ -23,6 +28,8 @@ Do not use this app with volumes containing sensitive data unless you know exact
- Unlocking volumes using fingerprint authentication
- Volume auto-locking when the app goes in background
_For upcoming features, see [TODO.md](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/TODO.md)._
# 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.
@ -32,23 +39,30 @@ Some available features are considered risky and are therefore disabled by defau
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>
<li><h4>Keep volume open when the app goes in background:</h4>
Don't close the volume when you leave the app but keep running it in the background. Anyone going back to the activity could have access to the volume.
</li>
<li><h4>Allow opening files with other applications*:</h4>
Decrypt and open file using external apps. These apps could save and send the files thus opened.
</li>
<li><h4>Expose open volumes*:</h4>
Allow open volumes to be browsed in the system file explorer (<a href="https://developer.android.com/guide/topics/providers/document-provider">DocumentProvider</a> API). Encrypted files can then be selected from other applications, potentially with permanent access. This feature requires <i>"Keep volume open when the app goes in background"</i> to be enabled.
</li>
<li><h4>Grant write access:</h4>
Files opened with another applications can be modified by them. This applies to both previous unsafe features.
</li>
</ul>
* Features requiring temporary writing of the plain file to disk (DroidFS internal storage). This file could be read by apps with root access or by physical access if your device is not encrypted.
\* These features can work in two ways: temporarily writing the plain file to disk (DroidFS internal storage) or sharing it via memory. By default, DroidFS will choose to keep the file only in memory as it's more secure, but will fallback to disk export if the file is too large to be held in memory. This behavior can be changed with the *"Export method"* parameter in the settings. Please note that some applications require the file to be stored on disk, and therefore do not work with memory-exported files.
# Download
<a href="https://f-droid.org/packages/sushi.hardcore.droidfs">
@ -92,7 +106,7 @@ DroidFS needs some permissions for certain features. However, you are free to de
</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), nor can it allow other applications to access encrypted volumes once opened.
DroidFS works as a wrapper around modified versions of the original encrypted container implementations ([libgocryptfs](https://forge.chapril.org/hardcoresushi/libgocryptfs) and [libcryfs](https://forge.chapril.org/hardcoresushi/libcryfs)). These programs were designed to run on standard x86 Linux systems: they access the underlying file system with file paths and syscalls. However, on Android, you can't access files from other applications using file paths. Instead, one has to use the [ContentProvider](https://developer.android.com/guide/topics/providers/content-providers) API. Obviously, neither Gocryptfs nor CryFS support this API. As a result, DroidFS cannot open volumes provided by other applications (such as cloud storage clients). If you want to synchronize your volumes on a cloud, the cloud application must synchronize the encrypted directory from disk.
Due to Android's storage restrictions, encrypted volumes located on SD cards must be placed under `/Android/data/sushi.hardcore.droidfs/` if you want DroidFS to be able to modify them.

View File

@ -1,6 +1,6 @@
# TODO
Here is a list of features that it would be nice to have in DroidFS.
Here's a list of features that it would be nice to have in DroidFS. As this is a FLOSS project, there are no special requirements on *when* or even *if* these features will be implemented, but contributions are greatly appreciated.
## Security
- [hardened_malloc](https://github.com/GrapheneOS/hardened_malloc) compatibility ([#181](https://github.com/hardcore-sushi/DroidFS/issues/181))
@ -8,10 +8,15 @@ Here is a list of features that it would be nice to have in DroidFS.
## UX
- File associations editor
- Optional discovery before file operations
- Modifiable CryFS scrypt parameters
- Alert dialog showing details of file operations
- Internal file browser to select volumes
## Encryption software support
- [Shufflecake](https://shufflecake.net): plausible deniability for multiple hidden filesystems on Linux (would be absolutely awesome to have but quite difficult)
- [fscrypt](https://www.kernel.org/doc/html/latest/filesystems/fscrypt.html): filesystem encryption at the kernel level
## Health
- F-Droid ABI split
- OpenSSL & FFmpeg as git submodules (useful for F-Droid)

View File

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

View File

@ -20,21 +20,25 @@ if (hasProperty("nosplits")) {
}
android {
compileSdkVersion 33
buildToolsVersion "33.0.0"
ndkVersion "25.1.8937393"
compileSdk 34
ndkVersion "26.1.10909125"
namespace "sushi.hardcore.droidfs"
compileOptions {
targetCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
defaultConfig {
applicationId "sushi.hardcore.droidfs"
minSdkVersion 21
targetSdkVersion 32
versionCode 31
versionName "2.0.1"
versionCode 36
versionName "2.1.3"
ndk {
abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
@ -54,12 +58,13 @@ android {
splits {
abi {
enable true
reset() // fix unknown bug (https://ru.stackoverflow.com/questions/1557805/abis-armeabi-mips-mips64-riscv64-are-not-supported-for-platform)
universalApk true
}
}
}
applicationVariants.all { variant ->
applicationVariants.configureEach { variant ->
variant.resValue "string", "versionName", variant.versionName
buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}"
buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}"
@ -67,6 +72,7 @@ android {
buildFeatures {
viewBinding true
buildConfig true
}
buildTypes {
@ -98,34 +104,34 @@ android {
dependencies {
implementation project(":libpdfviewer:app")
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.core:core-ktx:1.12.0'
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
def lifecycle_version = "2.6.1"
def lifecycle_version = "2.6.2"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
implementation "androidx.sqlite:sqlite-ktx:2.3.1"
implementation "androidx.preference:preference-ktx:1.2.0"
implementation "androidx.preference:preference-ktx:1.2.1"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation 'com.google.android.material:material:1.8.0'
implementation 'com.github.bumptech.glide:glide:4.15.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'com.github.bumptech.glide:glide:4.16.0'
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
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 media3_version = "1.1.1"
implementation "androidx.media3:media3-exoplayer:$media3_version"
implementation 'androidx.media3:media3-ui:1.1.1'
implementation "androidx.media3:media3-datasource:$media3_version"
implementation "androidx.concurrent:concurrent-futures:1.1.0"
def camerax_version = "1.3.0-alpha06"
def camerax_version = "1.3.0-rc02"
implementation "androidx.camera:camera-camera2:$camerax_version"
implementation "androidx.camera:camera-lifecycle:$camerax_version"
implementation "androidx.camera:camera-view:$camerax_version"
implementation "androidx.camera:camera-extensions:$camerax_version"
def autoValueVersion = "1.10.1"
def autoValueVersion = '1.10.4'
implementation "com.google.auto.value:auto-value-annotations:$autoValueVersion"
annotationProcessor "com.google.auto.value:auto-value:$autoValueVersion"
}

@ -1 +1 @@
Subproject commit f40c2bbdcde77d8128da181edb72bf0e7a2168b5
Subproject commit 6388eaf433a4196f10389921d5e346c90ee3d793

@ -1 +1 @@
Subproject commit 79f9a10e35847e46f8563941345355f15f2dba7c
Subproject commit 4f32853ae5ac70811b451cac60ed36fd5b93cbc8

View File

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

View File

@ -3,10 +3,6 @@
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<permission
android:name="${applicationId}.WRITE_TEMPORARY_STORAGE"
android:protectionLevel="signature" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
@ -57,6 +53,7 @@
<activity android:name=".file_viewers.AudioPlayer" android:configChanges="screenSize|orientation" />
<activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" />
<activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
<activity android:name=".LogcatActivity"/>
<service android:name=".WiperService" android:exported="false" android:stopWithTask="false"/>
<service android:name=".file_operations.FileOperationService" android:exported="false"/>
@ -68,10 +65,21 @@
</receiver>
<provider
android:name=".content_providers.RestrictedFileProvider"
android:name=".content_providers.TemporaryFileProvider"
android:authorities="${applicationId}.temporary_provider"
android:exported="true"/>
<provider
android:authorities="${applicationId}.volume_provider"
android:name=".content_providers.VolumeProvider"
android:exported="true"
android:writePermission="${applicationId}.WRITE_TEMPORARY_STORAGE" />
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>

View File

@ -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,6 +104,10 @@ public final class SucklessPendingRecording {
return mAudioEnabled;
}
boolean isPersistent() {
return mIsPersistent;
}
/**
* Enables audio to be recorded for this recording.
*
@ -139,6 +143,69 @@ 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.
*
@ -159,7 +226,13 @@ 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.
* the recording needs to be active. If the recording is garbage collected, the
* {@link VideoRecordEvent.Finalize} event will contain error
* {@link VideoRecordEvent.Finalize#ERROR_RECORDING_GARBAGE_COLLECTED}.
*
* <p>The {@link Recording} will be stopped automatically if the {@link VideoCapture} its
* {@link Recorder} is attached to is unbound unless it's created
* {@link #asPersistentRecording() as a persistent recording}.
*
* @throws IllegalStateException if the associated Recorder currently has an unfinished
* active recording.

View File

@ -16,6 +16,7 @@
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;
@ -54,6 +55,8 @@ 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;
@ -76,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.MimeInfo;
import androidx.camera.video.internal.config.AudioMimeInfo;
import androidx.camera.video.internal.encoder.AudioEncoderConfig;
import androidx.camera.video.internal.encoder.BufferCopiedEncodedData;
import androidx.camera.video.internal.encoder.EncodeException;
@ -340,10 +343,14 @@ public final class SucklessRecorder implements VideoOutput {
////////////////////////////////////////////////////////////////////////////////////////////////
// Members only accessed on mSequentialExecutor //
////////////////////////////////////////////////////////////////////////////////////////////////
private RecordingRecord mInProgressRecording = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
RecordingRecord mInProgressRecording = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
boolean mInProgressRecordingStopping = false;
private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null;
@Nullable
private SurfaceRequest.TransformationInfo mInProgressTransformationInfo = null;
@Nullable
private SurfaceRequest.TransformationInfo mSourceTransformationInfo = null;
private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
final List<ListenableFuture<Void>> mEncodingFutures = new ArrayList<>();
@ -424,13 +431,15 @@ 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 mNeedsReset = false;
private boolean mNeedsResetBeforeNextStart = 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,
@ -487,6 +496,13 @@ 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);
@ -756,7 +772,8 @@ public final class SucklessRecorder implements VideoOutput {
}
}
void stop(@NonNull SucklessRecording activeRecording) {
void stop(@NonNull SucklessRecording activeRecording, @VideoRecordError int error,
@Nullable Throwable errorCause) {
RecordingRecord pendingRecordingToFinalize = null;
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
@ -801,7 +818,7 @@ public final class SucklessRecorder implements VideoOutput {
long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
explicitlyStopTimeUs, ERROR_NONE, null));
explicitlyStopTimeUs, error, errorCause));
break;
case ERROR:
// In an error state, the recording will already be finalized. Treat as a
@ -811,9 +828,13 @@ 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."));
+ "produced.", errorCause));
}
}
@ -843,7 +864,8 @@ public final class SucklessRecorder implements VideoOutput {
recordingToFinalize.getOutputOptions(),
RecordingStats.of(/*duration=*/0L,
/*bytes=*/0L,
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause)),
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause,
AUDIO_AMPLITUDE_NONE)),
OutputResults.of(Uri.EMPTY),
error,
cause));
@ -874,14 +896,15 @@ 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);
requestReset(ERROR_SOURCE_INACTIVE, null, false);
} 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.
mNeedsReset = true;
if (mInProgressRecording != null) {
// Stop any in progress recording with "source inactive" error
mNeedsResetBeforeNextStart = true;
if (mInProgressRecording != null && !mInProgressRecording.isPersistent()) {
// Stop the in progress recording with "source inactive" error if it's not a
// persistent recording.
onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE,
null);
}
@ -905,7 +928,8 @@ public final class SucklessRecorder implements VideoOutput {
* the surface request complete callback first.
*/
@ExecutedBy("mSequentialExecutor")
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause) {
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause,
boolean videoOnly) {
boolean shouldReset = false;
boolean shouldStop = false;
synchronized (mLock) {
@ -927,14 +951,22 @@ 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 recording, stop it first then release the resources
// at onRecordingFinalized().
shouldStop = true;
// Fall-through
// 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 {
shouldStop = true;
setState(State.RESETTING);
}
break;
case STOPPING:
// Already stopping. Set state to RESETTING so resources will be released once
// onRecordingFinalized() runs.
@ -949,14 +981,17 @@ 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) {
reset();
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()) {
@ -964,16 +999,19 @@ public final class SucklessRecorder implements VideoOutput {
return;
}
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
(transformationInfo) -> mSurfaceTransformationInfo = transformationInfo);
(transformationInfo) -> mSourceTransformationInfo = transformationInfo);
Size surfaceSize = surfaceRequest.getResolution();
// Fetch and cache nearest encoder profiles, if one exists.
LegacyVideoCapabilities capabilities =
LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo());
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize);
DynamicRange dynamicRange = surfaceRequest.getDynamicRange();
VideoCapabilities capabilities = getVideoCapabilities(
surfaceRequest.getCamera().getCameraInfo());
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize,
dynamicRange);
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
+ " for surface size " + surfaceSize);
if (highestSupportedQuality != Quality.NONE) {
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality);
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality,
dynamicRange);
if (mResolvedEncoderProfiles == null) {
throw new AssertionError("Camera advertised available quality but did not "
+ "produce EncoderProfiles for advertised quality.");
@ -986,9 +1024,14 @@ 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)) {
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.
Logger.w(TAG, "Ignore the SurfaceRequest " + request + " isServiced: "
+ request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession);
+ request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession
+ " has been configured with a persistent in-progress recording.");
return;
}
VideoEncoderSession videoEncoderSession =
@ -1020,6 +1063,12 @@ 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() {
@ -1055,7 +1104,9 @@ public final class SucklessRecorder implements VideoOutput {
mVideoEncoderSessionToRelease = videoEncoderSession;
setLatestSurface(null);
requestReset(ERROR_SOURCE_INACTIVE, null);
// Only reset video if the in-progress recording is persistent.
requestReset(ERROR_SOURCE_INACTIVE, null,
isPersistentRecordingInProgress());
}
@Override
@ -1070,17 +1121,14 @@ 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 startRecordingPaused = false;
boolean recordingPaused = 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);
@ -1090,6 +1138,15 @@ 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;
@ -1098,7 +1155,7 @@ public final class SucklessRecorder implements VideoOutput {
"onConfigured() was invoked when the Recorder had encountered error");
break;
case PENDING_PAUSED:
startRecordingPaused = true;
recordingPaused = true;
// Fall through
case PENDING_RECORDING:
if (mActiveRecordingRecord != null) {
@ -1119,9 +1176,21 @@ public final class SucklessRecorder implements VideoOutput {
}
}
if (recordingToStart != null) {
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) {
// Start new active recording inline on sequential executor (but unlocked).
startRecording(recordingToStart, startRecordingPaused);
startRecording(recordingToStart, recordingPaused);
} else if (pendingRecordingToFinalize != null) {
finalizePendingRecording(pendingRecordingToFinalize, error, errorCause);
}
@ -1162,7 +1231,7 @@ public final class SucklessRecorder implements VideoOutput {
throws AudioSourceAccessException, InvalidConfigException {
MediaSpec mediaSpec = getObservableData(mMediaSpec);
// Resolve the audio mime info
MimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
AudioMimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
Timebase audioSourceTimebase = Timebase.UPTIME;
// Select and create the audio source
@ -1313,8 +1382,10 @@ public final class SucklessRecorder implements VideoOutput {
return;
}
if (mSurfaceTransformationInfo != null) {
mediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees());
SurfaceRequest.TransformationInfo transformationInfo = mSourceTransformationInfo;
if (transformationInfo != null) {
setInProgressTransformationInfo(transformationInfo);
mediaMuxer.setOrientationHint(transformationInfo.getRotationDegrees());
}
mVideoTrackIndex = mediaMuxer.addTrack(mVideoOutputConfig.getMediaFormat());
@ -1402,7 +1473,9 @@ public final class SucklessRecorder implements VideoOutput {
"The Recorder doesn't support recording with audio");
}
try {
setupAudio(recordingToStart);
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);
@ -1419,7 +1492,7 @@ public final class SucklessRecorder implements VideoOutput {
break;
}
initEncoderAndAudioSourceCallbacks(recordingToStart);
updateEncoderCallbacks(recordingToStart, false);
if (isAudioEnabled()) {
mAudioSource.start(recordingToStart.isMuted());
mAudioEncoder.start();
@ -1432,7 +1505,17 @@ public final class SucklessRecorder implements VideoOutput {
}
@ExecutedBy("mSequentialExecutor")
private void initEncoderAndAudioSourceCallbacks(@NonNull RecordingRecord recordingToStart) {
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();
}
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
completer -> {
mVideoEncoder.setEncoderCallback(new EncoderCallback() {
@ -1528,7 +1611,7 @@ public final class SucklessRecorder implements VideoOutput {
return "videoEncodingFuture";
}));
if (isAudioEnabled()) {
if (isAudioEnabled() && !videoOnly) {
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
completer -> {
Consumer<Throwable> audioErrorConsumer = throwable -> {
@ -1568,6 +1651,11 @@ public final class SucklessRecorder implements VideoOutput {
audioErrorConsumer.accept(throwable);
}
}
@Override
public void onAmplitudeValue(double maxAmplitude) {
mAudioAmplitude = maxAmplitude;
}
});
mAudioEncoder.setEncoderCallback(new EncoderCallback() {
@ -1654,12 +1742,16 @@ public final class SucklessRecorder implements VideoOutput {
@Override
public void onFailure(@NonNull Throwable t) {
Logger.d(TAG, "Encodings end with error: " + 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);
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);
}
}
},
// Can use direct executor since completers are always completed on sequential
@ -1800,11 +1892,20 @@ public final class SucklessRecorder implements VideoOutput {
if (isAudioEnabled()) {
mAudioEncoder.start();
}
mVideoEncoder.start();
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume(
mInProgressRecording.getOutputOptions(),
getInProgressRecordingStats()));
// 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;
}
}
}
@ -1898,13 +1999,12 @@ public final class SucklessRecorder implements VideoOutput {
mAudioEncoder = null;
mAudioOutputConfig = null;
}
tryReleaseVideoEncoder();
if (mAudioSource != null) {
releaseCurrentAudioSource();
}
setAudioState(AudioState.INITIALIZING);
onReset();
resetVideo();
}
@SuppressWarnings("FutureReturnValueIgnored")
@ -1926,7 +2026,8 @@ public final class SucklessRecorder implements VideoOutput {
}
@ExecutedBy("mSequentialExecutor")
private void onReset() {
private void onResetVideo() {
boolean shouldConfigure = true;
synchronized (mLock) {
switch (mState) {
case PENDING_PAUSED:
@ -1939,6 +2040,10 @@ public final class SucklessRecorder implements VideoOutput {
case PAUSED:
// Fall-through
case RECORDING:
if (isPersistentRecordingInProgress()) {
shouldConfigure = false;
break;
}
// Fall-through
case IDLING:
// Fall-through
@ -1953,14 +2058,24 @@ public final class SucklessRecorder implements VideoOutput {
}
}
mNeedsReset = false;
mNeedsResetBeforeNextStart = false;
// If the latest surface request hasn't been serviced, use it to re-configure the Recorder.
if (mLatestSurfaceRequest != null && !mLatestSurfaceRequest.isServiced()) {
if (shouldConfigure && 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) {
@ -2063,7 +2178,9 @@ public final class SucklessRecorder implements VideoOutput {
mRecordingStopError = ERROR_UNKNOWN;
mRecordingStopErrorCause = null;
mAudioErrorCause = null;
mAudioAmplitude = AUDIO_AMPLITUDE_NONE;
clearPendingAudioRingBuffer();
setInProgressTransformationInfo(null);
switch (mAudioState) {
case IDLING:
@ -2246,7 +2363,7 @@ public final class SucklessRecorder implements VideoOutput {
startRecordingPaused = true;
// Fall-through
case PENDING_RECORDING:
if (mActiveRecordingRecord != null || mNeedsReset) {
if (mActiveRecordingRecord != null || mNeedsResetBeforeNextStart) {
// Active recording is still finalizing or the Recorder is expected to be
// reset. Pending recording will be serviced in onRecordingFinalized() or
// in onReset().
@ -2361,7 +2478,8 @@ public final class SucklessRecorder implements VideoOutput {
@NonNull
RecordingStats getInProgressRecordingStats() {
return RecordingStats.of(mRecordingDurationNs, mRecordingBytes,
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause));
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause,
mAudioAmplitude));
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ -2415,7 +2533,7 @@ public final class SucklessRecorder implements VideoOutput {
if (streamState == null) {
streamState = internalStateToStreamState(mState);
}
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState));
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState, mInProgressTransformationInfo));
}
@ExecutedBy("mSequentialExecutor")
@ -2437,7 +2555,20 @@ public final class SucklessRecorder implements VideoOutput {
}
Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + streamId);
mStreamId = streamId;
mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState)));
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));
}
}
/**
@ -2460,8 +2591,8 @@ public final class SucklessRecorder implements VideoOutput {
if (mNonPendingState != state) {
mNonPendingState = state;
mStreamInfo.setState(
StreamInfo.of(mStreamId, internalStateToStreamState(state)));
mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(state),
mInProgressTransformationInfo));
}
}
@ -2509,6 +2640,21 @@ 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 {
@ -2537,6 +2683,7 @@ public final class SucklessRecorder implements VideoOutput {
pendingRecording.getListenerExecutor(),
pendingRecording.getEventListener(),
pendingRecording.isAudioEnabled(),
pendingRecording.isPersistent(),
recordingId
);
}
@ -2552,6 +2699,8 @@ public final class SucklessRecorder implements VideoOutput {
abstract boolean hasAudioEnabled();
abstract boolean isPersistent();
abstract long getRecordingId();
/**
@ -2782,7 +2931,12 @@ public final class SucklessRecorder implements VideoOutput {
throw new AssertionError("One-time media muxer creation has already occurred for"
+ " recording " + this);
}
return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback);
try {
return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback);
} catch (RuntimeException e) {
throw new IOException("Failed to create MediaMuxer by " + e, e);
}
}
/**

View File

@ -21,6 +21,7 @@ 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;
@ -56,13 +57,15 @@ 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 finalizedOnCreation) {
boolean isPersistent, boolean finalizedOnCreation) {
mRecorder = recorder;
mRecordingId = recordingId;
mOutputOptions = options;
mIsPersistent = isPersistent;
if (finalizedOnCreation) {
mIsClosed.set(true);
@ -83,6 +86,7 @@ public final class SucklessRecording implements AutoCloseable {
return new SucklessRecording(pendingRecording.getRecorder(),
recordingId,
pendingRecording.getOutputOptions(),
pendingRecording.isPersistent(),
/*finalizedOnCreation=*/false);
}
@ -103,6 +107,7 @@ public final class SucklessRecording implements AutoCloseable {
return new SucklessRecording(pendingRecording.getRecorder(),
recordingId,
pendingRecording.getOutputOptions(),
pendingRecording.isPersistent(),
/*finalizedOnCreation=*/true);
}
@ -111,6 +116,20 @@ 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.
*
@ -196,11 +215,7 @@ public final class SucklessRecording implements AutoCloseable {
*/
@Override
public void close() {
mCloseGuard.close();
if (mIsClosed.getAndSet(true)) {
return;
}
mRecorder.stop(this);
stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null);
}
@Override
@ -208,7 +223,8 @@ public final class SucklessRecording implements AutoCloseable {
protected void finalize() throws Throwable {
try {
mCloseGuard.warnIfOpen();
stop();
stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED,
new RuntimeException("Recording stopped due to being garbage collected."));
} finally {
super.finalize();
}
@ -234,5 +250,14 @@ 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);
}
}

View File

@ -35,6 +35,7 @@ import android.media.MediaCodec.BufferInfo;
import android.media.MediaCodecInfo;
import android.media.MediaFormat;
import android.os.Bundle;
import android.os.SystemClock;
import android.util.Range;
import android.view.Surface;
@ -1054,6 +1055,7 @@ public class SucklessEncoderImpl implements Encoder {
if (mIsVideoEncoder) {
Timebase inputTimebase;
if (DeviceQuirks.get(CameraUseInconsistentTimebaseQuirk.class) != null) {
Logger.w(mTag, "CameraUseInconsistentTimebaseQuirk is enabled");
inputTimebase = null;
} else {
inputTimebase = mInputTimebase;
@ -1065,7 +1067,7 @@ public class SucklessEncoderImpl implements Encoder {
}
@Override
public void onInputBufferAvailable(MediaCodec mediaCodec, int index) {
public void onInputBufferAvailable(@NonNull MediaCodec mediaCodec, int index) {
mEncoderExecutor.execute(() -> {
if (mStopped) {
Logger.w(mTag, "Receives input frame after codec is reset.");
@ -1131,6 +1133,15 @@ public class SucklessEncoderImpl implements Encoder {
if (checkBufferInfo(bufferInfo)) {
if (!mHasFirstData) {
mHasFirstData = true;
// Only print the first data to avoid flooding the log.
Logger.d(mTag,
"data timestampUs = " + bufferInfo.presentationTimeUs
+ ", data timebase = " + mInputTimebase
+ ", current system uptimeMs = "
+ SystemClock.uptimeMillis()
+ ", current system realtimeMs = "
+ SystemClock.elapsedRealtime()
);
}
BufferInfo outBufferInfo = resolveOutputBufferInfo(bufferInfo);
mLastSentAdjustedTimeUs = outBufferInfo.presentationTimeUs;
@ -1254,46 +1265,13 @@ public class SucklessEncoderImpl implements Encoder {
mVideoTimestampConverter.convertToUptimeUs(bufferInfo.presentationTimeUs);
}
// MediaCodec may send out of order buffer
if (bufferInfo.presentationTimeUs <= mLastPresentationTimeUs) {
Logger.d(mTag, "Drop buffer by out of order buffer from MediaCodec.");
return false;
}
mLastPresentationTimeUs = bufferInfo.presentationTimeUs;
// Ignore buffers are not in start/stop range. One situation is to ignore outdated
// frames when using the Surface of MediaCodec#createPersistentInputSurface. After
// the persistent Surface stops, it will keep a small number of old frames in its
// buffer, and send those old frames in the next startup.
if (!mStartStopTimeRangeUs.contains(bufferInfo.presentationTimeUs)) {
Logger.d(mTag, "Drop buffer by not in start-stop range.");
// If data hasn't reached the expected stop timestamp, set the stop timestamp.
if (mPendingCodecStop
&& bufferInfo.presentationTimeUs >= mStartStopTimeRangeUs.getUpper()) {
if (mStopTimeoutFuture != null) {
mStopTimeoutFuture.cancel(true);
}
mLastDataStopTimestamp = bufferInfo.presentationTimeUs;
signalCodecStop();
mPendingCodecStop = false;
}
return false;
}
if (updatePauseRangeStateAndCheckIfBufferPaused(bufferInfo)) {
Logger.d(mTag, "Drop buffer by pause.");
return false;
}
// We should check if the adjusted time is valid. see b/189114207.
if (getAdjustedTimeUs(bufferInfo) <= mLastSentAdjustedTimeUs) {
Logger.d(mTag, "Drop buffer by adjusted time is less than the last sent time.");
if (mIsVideoEncoder && isKeyFrame(bufferInfo)) {
mIsKeyFrameRequired = true;
}
return false;
}
if (!mHasFirstData && !mIsKeyFrameRequired && mIsVideoEncoder) {
mIsKeyFrameRequired = true;
}

View File

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

View File

@ -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,6 +102,10 @@ public final class PendingRecording {
return mAudioEnabled;
}
boolean isPersistent() {
return mIsPersistent;
}
/**
* Enables audio to be recorded for this recording.
*
@ -137,6 +141,69 @@ 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.
*
@ -157,7 +224,13 @@ 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.
* the recording needs to be active. If the recording is garbage collected, the
* {@link VideoRecordEvent.Finalize} event will contain error
* {@link VideoRecordEvent.Finalize#ERROR_RECORDING_GARBAGE_COLLECTED}.
*
* <p>The {@link Recording} will be stopped automatically if the {@link VideoCapture} its
* {@link Recorder} is attached to is unbound unless it's created
* {@link #asPersistentRecording() as a persistent recording}.
*
* @throws IllegalStateException if the associated Recorder currently has an unfinished
* active recording.

View File

@ -16,6 +16,7 @@
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;
@ -57,6 +58,8 @@ 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 +82,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.MimeInfo;
import androidx.camera.video.internal.config.AudioMimeInfo;
import androidx.camera.video.internal.encoder.AudioEncoderConfig;
import androidx.camera.video.internal.encoder.BufferCopiedEncodedData;
import androidx.camera.video.internal.encoder.EncodeException;
@ -343,10 +346,14 @@ public final class Recorder implements VideoOutput {
////////////////////////////////////////////////////////////////////////////////////////////////
// Members only accessed on mSequentialExecutor //
////////////////////////////////////////////////////////////////////////////////////////////////
private RecordingRecord mInProgressRecording = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
RecordingRecord mInProgressRecording = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
boolean mInProgressRecordingStopping = false;
private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null;
@Nullable
private SurfaceRequest.TransformationInfo mInProgressTransformationInfo = null;
@Nullable
private SurfaceRequest.TransformationInfo mSourceTransformationInfo = null;
private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null;
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
final List<ListenableFuture<Void>> mEncodingFutures = new ArrayList<>();
@ -427,13 +434,15 @@ 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 mNeedsReset = false;
private boolean mNeedsResetBeforeNextStart = 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,
@ -490,6 +499,13 @@ 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}.
*
@ -846,7 +862,8 @@ public final class Recorder implements VideoOutput {
}
}
void stop(@NonNull Recording activeRecording) {
void stop(@NonNull Recording activeRecording, @VideoRecordError int error,
@Nullable Throwable errorCause) {
RecordingRecord pendingRecordingToFinalize = null;
synchronized (mLock) {
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
@ -891,7 +908,7 @@ public final class Recorder implements VideoOutput {
long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
explicitlyStopTimeUs, ERROR_NONE, null));
explicitlyStopTimeUs, error, errorCause));
break;
case ERROR:
// In an error state, the recording will already be finalized. Treat as a
@ -901,9 +918,13 @@ 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."));
+ "produced.", errorCause));
}
}
@ -933,7 +954,8 @@ public final class Recorder implements VideoOutput {
recordingToFinalize.getOutputOptions(),
RecordingStats.of(/*duration=*/0L,
/*bytes=*/0L,
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause)),
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause,
AUDIO_AMPLITUDE_NONE)),
OutputResults.of(Uri.EMPTY),
error,
cause));
@ -964,14 +986,15 @@ 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);
requestReset(ERROR_SOURCE_INACTIVE, null, false);
} 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.
mNeedsReset = true;
if (mInProgressRecording != null) {
// Stop any in progress recording with "source inactive" error
mNeedsResetBeforeNextStart = true;
if (mInProgressRecording != null && !mInProgressRecording.isPersistent()) {
// Stop the in progress recording with "source inactive" error if it's not a
// persistent recording.
onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE,
null);
}
@ -995,7 +1018,8 @@ public final class Recorder implements VideoOutput {
* the surface request complete callback first.
*/
@ExecutedBy("mSequentialExecutor")
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause) {
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause,
boolean videoOnly) {
boolean shouldReset = false;
boolean shouldStop = false;
synchronized (mLock) {
@ -1017,14 +1041,22 @@ 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 recording, stop it first then release the resources
// at onRecordingFinalized().
shouldStop = true;
// Fall-through
// 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 {
shouldStop = true;
setState(State.RESETTING);
}
break;
case STOPPING:
// Already stopping. Set state to RESETTING so resources will be released once
// onRecordingFinalized() runs.
@ -1039,14 +1071,17 @@ 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) {
reset();
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()) {
@ -1054,16 +1089,19 @@ public final class Recorder implements VideoOutput {
return;
}
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
(transformationInfo) -> mSurfaceTransformationInfo = transformationInfo);
(transformationInfo) -> mSourceTransformationInfo = transformationInfo);
Size surfaceSize = surfaceRequest.getResolution();
// Fetch and cache nearest encoder profiles, if one exists.
LegacyVideoCapabilities capabilities =
LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo());
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize);
DynamicRange dynamicRange = surfaceRequest.getDynamicRange();
VideoCapabilities capabilities = getVideoCapabilities(
surfaceRequest.getCamera().getCameraInfo());
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize,
dynamicRange);
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
+ " for surface size " + surfaceSize);
if (highestSupportedQuality != Quality.NONE) {
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality);
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality,
dynamicRange);
if (mResolvedEncoderProfiles == null) {
throw new AssertionError("Camera advertised available quality but did not "
+ "produce EncoderProfiles for advertised quality.");
@ -1076,9 +1114,14 @@ 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)) {
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.
Logger.w(TAG, "Ignore the SurfaceRequest " + request + " isServiced: "
+ request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession);
+ request.isServiced() + " VideoEncoderSession: " + mVideoEncoderSession
+ " has been configured with a persistent in-progress recording.");
return;
}
VideoEncoderSession videoEncoderSession =
@ -1110,6 +1153,12 @@ 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() {
@ -1145,7 +1194,9 @@ public final class Recorder implements VideoOutput {
mVideoEncoderSessionToRelease = videoEncoderSession;
setLatestSurface(null);
requestReset(ERROR_SOURCE_INACTIVE, null);
// Only reset video if the in-progress recording is persistent.
requestReset(ERROR_SOURCE_INACTIVE, null,
isPersistentRecordingInProgress());
}
@Override
@ -1160,17 +1211,14 @@ 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 startRecordingPaused = false;
boolean recordingPaused = 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);
@ -1180,6 +1228,15 @@ 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;
@ -1188,7 +1245,7 @@ public final class Recorder implements VideoOutput {
"onConfigured() was invoked when the Recorder had encountered error");
break;
case PENDING_PAUSED:
startRecordingPaused = true;
recordingPaused = true;
// Fall through
case PENDING_RECORDING:
if (mActiveRecordingRecord != null) {
@ -1209,9 +1266,21 @@ public final class Recorder implements VideoOutput {
}
}
if (recordingToStart != null) {
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) {
// Start new active recording inline on sequential executor (but unlocked).
startRecording(recordingToStart, startRecordingPaused);
startRecording(recordingToStart, recordingPaused);
} else if (pendingRecordingToFinalize != null) {
finalizePendingRecording(pendingRecordingToFinalize, error, errorCause);
}
@ -1252,7 +1321,7 @@ public final class Recorder implements VideoOutput {
throws AudioSourceAccessException, InvalidConfigException {
MediaSpec mediaSpec = getObservableData(mMediaSpec);
// Resolve the audio mime info
MimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
AudioMimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
Timebase audioSourceTimebase = Timebase.UPTIME;
// Select and create the audio source
@ -1403,8 +1472,10 @@ public final class Recorder implements VideoOutput {
return;
}
if (mSurfaceTransformationInfo != null) {
mediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees());
SurfaceRequest.TransformationInfo transformationInfo = mSourceTransformationInfo;
if (transformationInfo != null) {
setInProgressTransformationInfo(transformationInfo);
mediaMuxer.setOrientationHint(transformationInfo.getRotationDegrees());
}
Location location = recordingToStart.getOutputOptions().getLocation();
if (location != null) {
@ -1507,7 +1578,9 @@ public final class Recorder implements VideoOutput {
"The Recorder doesn't support recording with audio");
}
try {
setupAudio(recordingToStart);
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);
@ -1524,7 +1597,7 @@ public final class Recorder implements VideoOutput {
break;
}
initEncoderAndAudioSourceCallbacks(recordingToStart);
updateEncoderCallbacks(recordingToStart, false);
if (isAudioEnabled()) {
mAudioSource.start(recordingToStart.isMuted());
mAudioEncoder.start();
@ -1537,7 +1610,17 @@ public final class Recorder implements VideoOutput {
}
@ExecutedBy("mSequentialExecutor")
private void initEncoderAndAudioSourceCallbacks(@NonNull RecordingRecord recordingToStart) {
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();
}
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
completer -> {
mVideoEncoder.setEncoderCallback(new EncoderCallback() {
@ -1633,7 +1716,7 @@ public final class Recorder implements VideoOutput {
return "videoEncodingFuture";
}));
if (isAudioEnabled()) {
if (isAudioEnabled() && !videoOnly) {
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
completer -> {
Consumer<Throwable> audioErrorConsumer = throwable -> {
@ -1673,6 +1756,11 @@ public final class Recorder implements VideoOutput {
audioErrorConsumer.accept(throwable);
}
}
@Override
public void onAmplitudeValue(double maxAmplitude) {
mAudioAmplitude = maxAmplitude;
}
});
mAudioEncoder.setEncoderCallback(new EncoderCallback() {
@ -1759,12 +1847,16 @@ public final class Recorder implements VideoOutput {
@Override
public void onFailure(@NonNull Throwable t) {
Logger.d(TAG, "Encodings end with error: " + 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);
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);
}
}
},
// Can use direct executor since completers are always completed on sequential
@ -1905,11 +1997,20 @@ public final class Recorder implements VideoOutput {
if (isAudioEnabled()) {
mAudioEncoder.start();
}
mVideoEncoder.start();
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume(
mInProgressRecording.getOutputOptions(),
getInProgressRecordingStats()));
// 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;
}
}
}
@ -2003,13 +2104,12 @@ public final class Recorder implements VideoOutput {
mAudioEncoder = null;
mAudioOutputConfig = null;
}
tryReleaseVideoEncoder();
if (mAudioSource != null) {
releaseCurrentAudioSource();
}
setAudioState(AudioState.INITIALIZING);
onReset();
resetVideo();
}
@SuppressWarnings("FutureReturnValueIgnored")
@ -2031,7 +2131,8 @@ public final class Recorder implements VideoOutput {
}
@ExecutedBy("mSequentialExecutor")
private void onReset() {
private void onResetVideo() {
boolean shouldConfigure = true;
synchronized (mLock) {
switch (mState) {
case PENDING_PAUSED:
@ -2044,6 +2145,10 @@ public final class Recorder implements VideoOutput {
case PAUSED:
// Fall-through
case RECORDING:
if (isPersistentRecordingInProgress()) {
shouldConfigure = false;
break;
}
// Fall-through
case IDLING:
// Fall-through
@ -2058,14 +2163,24 @@ public final class Recorder implements VideoOutput {
}
}
mNeedsReset = false;
mNeedsResetBeforeNextStart = false;
// If the latest surface request hasn't been serviced, use it to re-configure the Recorder.
if (mLatestSurfaceRequest != null && !mLatestSurfaceRequest.isServiced()) {
if (shouldConfigure && 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) {
@ -2168,7 +2283,9 @@ public final class Recorder implements VideoOutput {
mRecordingStopError = ERROR_UNKNOWN;
mRecordingStopErrorCause = null;
mAudioErrorCause = null;
mAudioAmplitude = AUDIO_AMPLITUDE_NONE;
clearPendingAudioRingBuffer();
setInProgressTransformationInfo(null);
switch (mAudioState) {
case IDLING:
@ -2351,7 +2468,7 @@ public final class Recorder implements VideoOutput {
startRecordingPaused = true;
// Fall-through
case PENDING_RECORDING:
if (mActiveRecordingRecord != null || mNeedsReset) {
if (mActiveRecordingRecord != null || mNeedsResetBeforeNextStart) {
// Active recording is still finalizing or the Recorder is expected to be
// reset. Pending recording will be serviced in onRecordingFinalized() or
// in onReset().
@ -2466,7 +2583,8 @@ public final class Recorder implements VideoOutput {
@NonNull
RecordingStats getInProgressRecordingStats() {
return RecordingStats.of(mRecordingDurationNs, mRecordingBytes,
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause));
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause,
mAudioAmplitude));
}
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
@ -2520,7 +2638,7 @@ public final class Recorder implements VideoOutput {
if (streamState == null) {
streamState = internalStateToStreamState(mState);
}
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState));
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState, mInProgressTransformationInfo));
}
@ExecutedBy("mSequentialExecutor")
@ -2542,7 +2660,20 @@ public final class Recorder implements VideoOutput {
}
Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + streamId);
mStreamId = streamId;
mStreamInfo.setState(StreamInfo.of(streamId, internalStateToStreamState(mState)));
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));
}
}
/**
@ -2565,8 +2696,8 @@ public final class Recorder implements VideoOutput {
if (mNonPendingState != state) {
mNonPendingState = state;
mStreamInfo.setState(
StreamInfo.of(mStreamId, internalStateToStreamState(state)));
mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(state),
mInProgressTransformationInfo));
}
}
@ -2614,6 +2745,21 @@ 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 {
@ -2642,6 +2788,7 @@ public final class Recorder implements VideoOutput {
pendingRecording.getListenerExecutor(),
pendingRecording.getEventListener(),
pendingRecording.isAudioEnabled(),
pendingRecording.isPersistent(),
recordingId
);
}
@ -2657,6 +2804,8 @@ public final class Recorder implements VideoOutput {
abstract boolean hasAudioEnabled();
abstract boolean isPersistent();
abstract long getRecordingId();
/**
@ -2720,8 +2869,13 @@ public final class Recorder implements VideoOutput {
// Toggle on pending status for the video file.
contentValues.put(MediaStore.Video.Media.IS_PENDING, PENDING);
}
outputUri = mediaStoreOutputOptions.getContentResolver().insert(
mediaStoreOutputOptions.getCollectionUri(), contentValues);
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.");
}
@ -2943,7 +3097,12 @@ public final class Recorder implements VideoOutput {
throw new AssertionError("One-time media muxer creation has already occurred for"
+ " recording " + this);
}
return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback);
try {
return mediaMuxerSupplier.get(muxerOutputFormat, outputUriCreatedCallback);
} catch (RuntimeException e) {
throw new IOException("Failed to create MediaMuxer by " + e, e);
}
}
/**

View File

@ -19,6 +19,7 @@ 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;
@ -53,13 +54,15 @@ 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 finalizedOnCreation) {
boolean isPersistent, boolean finalizedOnCreation) {
mRecorder = recorder;
mRecordingId = recordingId;
mOutputOptions = options;
mIsPersistent = isPersistent;
if (finalizedOnCreation) {
mIsClosed.set(true);
@ -80,6 +83,7 @@ public final class Recording implements AutoCloseable {
return new Recording(pendingRecording.getRecorder(),
recordingId,
pendingRecording.getOutputOptions(),
pendingRecording.isPersistent(),
/*finalizedOnCreation=*/false);
}
@ -100,6 +104,7 @@ public final class Recording implements AutoCloseable {
return new Recording(pendingRecording.getRecorder(),
recordingId,
pendingRecording.getOutputOptions(),
pendingRecording.isPersistent(),
/*finalizedOnCreation=*/true);
}
@ -108,6 +113,20 @@ 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.
*
@ -193,11 +212,7 @@ public final class Recording implements AutoCloseable {
*/
@Override
public void close() {
mCloseGuard.close();
if (mIsClosed.getAndSet(true)) {
return;
}
mRecorder.stop(this);
stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null);
}
@Override
@ -205,7 +220,8 @@ public final class Recording implements AutoCloseable {
protected void finalize() throws Throwable {
try {
mCloseGuard.warnIfOpen();
stop();
stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED,
new RuntimeException("Recording stopped due to being garbage collected."));
} finally {
super.finalize();
}
@ -231,5 +247,14 @@ 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);
}
}

View File

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

View File

@ -2,10 +2,7 @@ package sushi.hardcore.droidfs
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.os.Build
import android.os.Bundle
import android.text.InputType
@ -14,20 +11,20 @@ import android.view.*
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.view.animation.RotateAnimation
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.RelativeLayout
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.core.AspectRatio
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.DynamicRange
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.core.UseCase
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.extensions.ExtensionMode
import androidx.camera.extensions.ExtensionsManager
import androidx.camera.lifecycle.ProcessCameraProvider
@ -47,6 +44,7 @@ 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.video_recording.AsynchronousSeekableWriter
import sushi.hardcore.droidfs.video_recording.FFmpegMuxer
import sushi.hardcore.droidfs.video_recording.SeekableWriter
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
@ -56,6 +54,8 @@ import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.Executor
import kotlin.math.pow
import kotlin.math.sqrt
@SuppressLint("RestrictedApi")
class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
@ -78,6 +78,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
}
}
private lateinit var sensorOrientationListener: SensorOrientationListener
private var currentRotation = 0
private var previousOrientation: Float = 0f
private lateinit var orientedIcons: List<ImageView>
private lateinit var encryptedVolume: EncryptedVolume
@ -380,11 +381,18 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
imageCapture = ImageCapture.Builder()
.setCaptureMode(captureMode)
.setFlashMode(imageCapture?.flashMode ?: ImageCapture.FLASH_MODE_AUTO)
.apply {
currentResolution?.let {
setTargetResolution(it)
.setResolutionSelector(ResolutionSelector.Builder().setResolutionFilter { supportedSizes, _ ->
resolutions = supportedSizes.sortedBy {
-it.width*it.height
}
}
currentResolution?.let { targetResolution ->
return@setResolutionFilter supportedSizes.sortedBy {
sqrt((it.width - targetResolution.width).toDouble().pow(2) + (it.height - targetResolution.height).toDouble().pow(2))
}
}
supportedSizes
}.build())
.setTargetRotation(currentRotation)
.build()
}
@ -396,7 +404,9 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
recorderBuilder.setQualitySelector(QualitySelector.from(qualities!![currentQualityIndex]))
}
videoRecorder = recorderBuilder.build()
videoCapture = VideoCapture.withOutput(videoRecorder!!)
videoCapture = VideoCapture.withOutput(videoRecorder!!).apply {
targetRotation = currentRotation
}
}
private fun rebindUseCases(): UseCase {
@ -405,20 +415,12 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
refreshVideoCapture()
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture)
if (qualities == null) {
qualities = QualitySelector.getSupportedQualities(camera!!.cameraInfo)
qualities = SucklessRecorder.getVideoCapabilities(camera!!.cameraInfo).getSupportedQualities(DynamicRange.UNSPECIFIED)
}
videoCapture
} else {
refreshImageCapture()
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, imageCapture)
if (resolutions == null) {
val info = Camera2CameraInfo.from(camera!!.cameraInfo)
val cameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
val characteristics = cameraManager.getCameraCharacteristics(info.cameraId)
characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)?.let { streamConfigurationMap ->
resolutions = streamConfigurationMap.getOutputSizes(imageCapture!!.imageFormat).map { it.swap() }
}
}
imageCapture
})!!
adaptPreviewSize(currentUseCase.attachedSurfaceResolution!!.swap())
@ -510,37 +512,32 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
.show()
return
}
startTimerThen {
var withAudio = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
withAudio = false
}
val writer = AsynchronousSeekableWriter(object : SeekableWriter {
private var offset = 0L
override fun close() {
encryptedVolume.closeFile(fileHandle)
}
videoRecording = videoRecorder?.prepareRecording(
this,
MuxerOutputOptions(
FFmpegMuxer(object : SeekableWriter {
private var offset = 0L
override fun close() {
encryptedVolume.closeFile(fileHandle)
}
override fun seek(offset: Long) {
this.offset = offset
}
override fun seek(offset: Long) {
this.offset = offset
}
override fun write(buffer: ByteArray) {
offset += encryptedVolume.write(fileHandle, offset, buffer, 0, buffer.size.toLong())
}
})
)
)?.apply {
if (withAudio) {
withAudioEnabled()
}
}?.start(executor) {
override fun write(buffer: ByteArray, size: Int) {
offset += encryptedVolume.write(fileHandle, offset, buffer, 0, size.toLong())
}
})
val pendingRecording = videoRecorder!!.prepareRecording(
this,
MuxerOutputOptions(FFmpegMuxer(writer))
).also {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
it.withAudioEnabled()
}
}
startTimerThen {
writer.start()
videoRecording = pendingRecording.start(executor) {
val buttons = arrayOf(binding.imageCaptureMode, binding.imageRatio, binding.imageTimer, binding.imageModeSwitch, binding.imageCameraSwitch)
when (it) {
is VideoRecordEvent.Start -> {
@ -606,6 +603,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
previousOrientation = realOrientation
imageCapture?.targetRotation = newOrientation
videoCapture?.targetRotation = newOrientation
currentRotation = newOrientation
}
}

View File

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

View File

@ -0,0 +1,250 @@
package sushi.hardcore.droidfs
import android.app.ActivityManager
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.ParcelFileDescriptor
import android.system.Os
import android.util.Log
import androidx.preference.PreferenceManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.Wiper
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
class EncryptedFileProvider(context: Context) {
companion object {
private const val TAG = "EncryptedFileProvider"
fun getTmpFilesDir(context: Context) = File(context.cacheDir, "tmp")
var exportMethod = ExportMethod.AUTO
}
enum class ExportMethod {
AUTO,
DISK,
MEMORY;
companion object {
fun parse(value: String) = when (value) {
"auto" -> EncryptedFileProvider.ExportMethod.AUTO
"disk" -> EncryptedFileProvider.ExportMethod.DISK
"memory" -> EncryptedFileProvider.ExportMethod.MEMORY
else -> throw IllegalArgumentException("Invalid export method: $value")
}
}
}
private val memoryInfo = ActivityManager.MemoryInfo()
private val isMemFileSupported = Compat.isMemFileSupported()
private val tmpFilesDir by lazy { getTmpFilesDir(context) }
private val handler by lazy { Handler(context.mainLooper) }
init {
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(
memoryInfo
)
PreferenceManager.getDefaultSharedPreferences(context)
.getString("export_method", null)?.let {
exportMethod = ExportMethod.parse(it)
}
}
class ExportedDiskFile private constructor(
path: String,
private val file: File,
private val handler: Handler
) : ExportedFile(path) {
companion object {
fun create(path: String, tmpFilesDir: File, handler: Handler): ExportedDiskFile? {
val uuid = UUID.randomUUID().toString()
val file = File(tmpFilesDir, uuid)
return if (file.createNewFile()) {
ExportedDiskFile(path, file, handler)
} else {
null
}
}
}
override fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor {
return if (furtive) {
ParcelFileDescriptor.open(file, mode, handler) {
free()
}
} else {
ParcelFileDescriptor.open(file, mode)
}
}
override fun free() {
Wiper.wipe(file)
}
}
class ExportedMemFile private constructor(path: String, private val file: MemFile) :
ExportedFile(path) {
companion object {
fun create(path: String, size: Long): ExportedMemFile? {
val uuid = UUID.randomUUID().toString()
MemFile.create(uuid, size)?.let {
return ExportedMemFile(path, it)
}
return null
}
}
override fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor {
val fd = if (furtive) {
file.toParcelFileDescriptor()
} else {
file.dup()
}
if (mode and ParcelFileDescriptor.MODE_TRUNCATE != 0) {
Os.ftruncate(fd.fileDescriptor, 0)
} else {
FileInputStream(fd.fileDescriptor).apply {
channel.position(0)
close()
}
}
return fd
}
override fun free() = file.close()
}
abstract class ExportedFile(val path: String) {
var isValid = true
private set
fun invalidate() {
isValid = false
}
/**
* @param furtive If set to true, the file will be deleted when closed
*/
abstract fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor
abstract fun free()
}
fun createFile(
path: String,
size: Long,
): ExportedFile? {
val diskFile by lazy { ExportedDiskFile.create(path, tmpFilesDir, handler) }
val memFile by lazy { ExportedMemFile.create(path, size) }
return when (exportMethod) {
ExportMethod.MEMORY -> memFile
ExportMethod.DISK -> diskFile
ExportMethod.AUTO -> {
if (isMemFileSupported && size < memoryInfo.availMem * 0.8) {
memFile
} else {
diskFile
}
}
}
}
fun exportFile(
exportedFile: ExportedFile,
encryptedVolume: EncryptedVolume,
): Boolean {
val fd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false).fileDescriptor
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(fd))
}
enum class Error {
SUCCESS,
INVALID_STATE,
WRITE_ACCESS_DENIED,
UNSUPPORTED_APPEND,
UNSUPPORTED_RW,
;
fun log() {
Log.e(
TAG, when (this) {
SUCCESS -> "No error"
INVALID_STATE -> "Read after write is not supported"
WRITE_ACCESS_DENIED -> "Write access unauthorized"
UNSUPPORTED_APPEND -> "Appending is not supported"
UNSUPPORTED_RW -> "Read-write access requires Android 11 or later"
}
)
}
}
/**
* @param furtive If set to true, the file will be deleted when closed
*/
fun openFile(
file: ExportedFile,
mode: String,
encryptedVolume: EncryptedVolume,
volumeScope: CoroutineScope,
furtive: Boolean,
allowWrites: Boolean,
): Pair<ParcelFileDescriptor?, Error> {
val mode = ParcelFileDescriptor.parseMode(mode)
return if (mode and ParcelFileDescriptor.MODE_READ_ONLY != 0) {
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
Pair(file.open(mode, furtive), Error.SUCCESS)
} else {
if (!allowWrites) {
return Pair(null, Error.WRITE_ACCESS_DENIED)
}
fun import(input: InputStream): Boolean {
return if (encryptedVolume.importFile(input, file.path)) {
true
} else {
Log.e(TAG, "Failed to import file")
false
}
}
if (mode and ParcelFileDescriptor.MODE_WRITE_ONLY != 0) {
if (mode and ParcelFileDescriptor.MODE_APPEND != 0) {
return Pair(null, Error.UNSUPPORTED_APPEND)
}
if (mode and ParcelFileDescriptor.MODE_TRUNCATE == 0) {
Log.w(TAG, "Truncating file despite not being requested")
}
val pipe = ParcelFileDescriptor.createReliablePipe()
val input = FileInputStream(pipe[0].fileDescriptor)
volumeScope.launch {
if (import(input)) {
file.invalidate()
}
}
Pair(pipe[1], Error.SUCCESS)
} else { // read-write
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val fd = file.open(mode, false)
Pair(ParcelFileDescriptor.wrap(fd, handler) { e ->
if (e == null) {
import(FileInputStream(fd.fileDescriptor))
if (furtive) {
file.free()
}
}
}, Error.SUCCESS)
} else {
Pair(null, Error.UNSUPPORTED_RW)
}
}
}
}
}

View File

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

View File

@ -6,7 +6,7 @@ object FileTypes {
private val FILE_EXTENSIONS = mapOf(
Pair("image", listOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "heic")),
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac")),
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac", "opus")),
Pair("pdf", listOf("pdf")),
Pair("text", listOf(
"asc",

View File

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

View File

@ -20,12 +20,15 @@ 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
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
@ -46,9 +49,9 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
finish()
}
}
private var changePasswordPosition: Int? = null
private var selectedVolumePosition: Int? = null
private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
changePasswordPosition?.let { unselect(it) }
selectedVolumePosition?.let { unselect(it) }
}
private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null)
@ -147,6 +150,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
recreate()
} else {
volumeAdapter.refresh()
invalidateOptionsMenu()
if (volumeAdapter.volumes.isNotEmpty()) {
binding.textNoVolumes.visibility = View.GONE
}
@ -193,7 +197,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
private fun removeVolume(volume: VolumeData) {
volumeManager.getVolumeId(volume)?.let { volumeManager.closeVolume(it) }
volumeDatabase.removeVolume(volume.name)
volumeDatabase.removeVolume(volume)
}
private fun removeVolumes(volumes: List<VolumeData>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
@ -292,9 +296,9 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
true
}
R.id.change_password -> {
changePasswordPosition = volumeAdapter.selectedItems.elementAt(0)
selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
changePassword.launch(Intent(this, ChangePasswordActivity::class.java).apply {
putExtra("volume", volumeAdapter.volumes[changePasswordPosition!!])
putExtra("volume", volumeAdapter.volumes[selectedVolumePosition!!])
})
true
}
@ -304,26 +308,32 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
true
}
R.id.copy -> {
val position = volumeAdapter.selectedItems.elementAt(0)
val volume = volumeAdapter.volumes[position]
when {
volume.isHidden -> {
PathUtils.safePickDirectory(pickDirectory, this, theme)
}
File(filesDir, volume.shortName).exists() -> {
selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
val volume = volumeAdapter.volumes[selectedVolumePosition!!]
if (volume.isHidden) {
PathUtils.safePickDirectory(pickDirectory, this, theme)
} else {
val hiddenVolumeFile = File(VolumeData.getHiddenVolumeFullPath(filesDir.path, volume.shortName))
if (hiddenVolumeFile.exists()) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(R.string.hidden_volume_already_exists)
.setPositiveButton(R.string.ok, null)
.show()
}
else -> {
unselect(position)
} else {
unselect(selectedVolumePosition!!)
copyVolume(
DocumentFile.fromFile(File(volume.name)),
DocumentFile.fromFile(filesDir),
DocumentFile.fromFile(hiddenVolumeFile.parentFile!!),
) {
VolumeData(volume.shortName, true, volume.type, volume.encryptedHash, volume.iv)
VolumeData(
VolumeData.newUuid(),
volume.shortName,
true,
volume.type,
volume.encryptedHash,
volume.iv
)
}
}
}
@ -345,7 +355,11 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main_activity, menu)
menu.findItem(R.id.settings).isVisible = !explorerRouter.pickMode && !explorerRouter.dropMode
val settingsVisible = !explorerRouter.pickMode && !explorerRouter.dropMode
menu.findItem(R.id.settings).isVisible = settingsVisible
if (settingsVisible) {
UIUtils.getMenuIconNeutralTint(this, menu).applyTo(R.id.settings, R.drawable.icon_settings)
}
val isSelecting = volumeAdapter.selectedItems.isNotEmpty()
menu.findItem(R.id.select_all).isVisible = isSelecting
menu.findItem(R.id.lock).isVisible = isSelecting && volumeAdapter.selectedItems.any {
@ -373,15 +387,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
)
}
}
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable && !volumeManager.isOpen(volumeAdapter.volumes[volumeAdapter.selectedItems.first()])
supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode)
return true
}
private fun onDirectoryPicked(uri: Uri) {
val position = volumeAdapter.selectedItems.elementAt(0)
val volume = volumeAdapter.volumes[position]
unselect(position)
val volume = volumeAdapter.volumes[selectedVolumePosition!!]
unselect(selectedVolumePosition!!)
val dstDocumentFile = DocumentFile.fromTreeUri(this, uri)
if (dstDocumentFile == null) {
CustomAlertDialogBuilder(this, theme)
@ -398,6 +411,7 @@ 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,
@ -409,14 +423,21 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
}
/**
* Copy a volume.
*
* @param srcDocumentFile [DocumentFile] of the volume to copy
* @param dstDocumentFile [DocumentFile] of the destination PARENT FOLDER
* @param getResultVolume A function that returns the [VolumeData] corresponding to the destination volume. Takes the [DocumentFile] of the newly created volume (not the parent folder).
*/
private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> VolumeData?) {
lifecycleScope.launch {
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
when {
result.taskResult.cancelled -> {
when (result.taskResult.state) {
TaskResult.State.CANCELLED -> {
result.dstRootDirectory?.delete()
}
result.taskResult.failedItem == null -> {
TaskResult.State.SUCCESS -> {
result.dstRootDirectory?.let {
getResultVolume(it)?.let { volume ->
volumeDatabase.saveVolume(volume)
@ -429,13 +450,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
}
}
}
else -> {
TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this@MainActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem.name))
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem!!.name))
.setPositiveButton(R.string.ok, null)
.show()
}
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
}
}
}
@ -458,7 +480,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
DocumentFile.fromFile(srcPath).renameTo(newName)
}
if (success) {
volumeDatabase.renameVolume(volume.name, newDBName)
volumeDatabase.renameVolume(volume, newDBName)
VolumeProvider.notifyRootsChanged(this)
unselect(position)
if (volume.name == volumeOpener.defaultVolumeName) {
with (sharedPrefs.edit()) {

View File

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

View File

@ -7,8 +7,16 @@ import android.os.Bundle
import android.text.InputType
import android.view.MenuItem
import android.widget.Toast
import androidx.preference.*
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.Compat
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -112,6 +120,10 @@ class SettingsActivity : BaseActivity() {
false
}
}
findPreference<Preference>("logcat")?.setOnPreferenceClickListener { _ ->
startActivity(Intent(requireContext(), LogcatActivity::class.java))
true
}
}
}
@ -150,6 +162,50 @@ class SettingsActivity : BaseActivity() {
true
}
}
val switchKeepOpen = findPreference<SwitchPreference>("usf_keep_open")!!
val switchExternalOpen = findPreference<SwitchPreference>("usf_open")!!
val switchExpose = findPreference<SwitchPreference>("usf_expose")!!
val switchSafWrite = findPreference<SwitchPreference>("usf_saf_write")!!
fun updateView(usfOpen: Boolean? = null, usfKeepOpen: Boolean? = null, usfExpose: Boolean? = null) {
val usfKeepOpen = usfKeepOpen ?: switchKeepOpen.isChecked
switchExpose.isEnabled = usfKeepOpen
switchSafWrite.isEnabled = usfOpen ?: switchExternalOpen.isChecked || (usfKeepOpen && usfExpose ?: switchExpose.isChecked)
}
updateView()
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
updateView(usfKeepOpen = checked as Boolean)
true
}
switchExternalOpen.setOnPreferenceChangeListener { _, checked ->
updateView(usfOpen = checked as Boolean)
true
}
switchExpose.setOnPreferenceChangeListener { _, checked ->
VolumeProvider.usfExpose = checked as Boolean
updateView(usfExpose = checked)
VolumeProvider.notifyRootsChanged(requireContext())
true
}
switchSafWrite.setOnPreferenceChangeListener { _, checked ->
VolumeProvider.usfSafWrite = checked as Boolean
TemporaryFileProvider.usfSafWrite = checked
true
}
findPreference<ListPreference>("export_method")!!.setOnPreferenceChangeListener { _, newValue ->
if (newValue as String == "memory" && !Compat.isMemFileSupported()) {
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.memfd_create_unsupported, Compat.MEMFD_CREATE_MINIMUM_KERNEL_VERSION))
.setPositiveButton(R.string.ok, null)
.show()
return@setOnPreferenceChangeListener false
}
EncryptedFileProvider.exportMethod = EncryptedFileProvider.ExportMethod.parse(newValue)
true
}
}
}
}

View File

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

View File

@ -2,12 +2,25 @@ package sushi.hardcore.droidfs
import android.os.Parcel
import android.os.Parcelable
import sushi.hardcore.droidfs.filesystems.CryfsVolume
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.PathUtils
import java.io.File
import java.io.FileInputStream
import java.util.UUID
class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte, var encryptedHash: ByteArray? = null, var iv: ByteArray? = null): Parcelable {
class VolumeData(
val uuid: String,
val name: String,
val isHidden: Boolean = false,
val type: Byte,
var encryptedHash: ByteArray? = null,
var iv: ByteArray? = null
) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readString()!!,
parcel.readByte() != 0.toByte(),
parcel.readByte(),
@ -19,11 +32,28 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
File(name).name
}
fun getFullPath(filesDir: String): String {
return if (isHidden)
getHiddenVolumeFullPath(filesDir, name)
else
name
fun getFullPath(filesDir: String) = getFullPath(name, isHidden, filesDir)
fun canRead(filesDir: String): Boolean {
val volumePath = getFullPath(filesDir)
if (!File(volumePath).canRead()) {
return false
}
val configFile = when (type) {
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE -> PathUtils.pathJoin(volumePath, GocryptfsVolume.CONFIG_FILE_NAME)
EncryptedVolume.CRYFS_VOLUME_TYPE -> PathUtils.pathJoin(volumePath, CryfsVolume.CONFIG_FILE_NAME)
else -> return false
}
var success = true
try {
with (FileInputStream(configFile)) {
read()
close()
}
} catch (e: Exception) {
success = false
}
return success
}
fun canWrite(filesDir: String): Boolean {
@ -36,6 +66,7 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
override fun writeToParcel(dest: Parcel, flags: Int) {
with (dest) {
writeString(uuid)
writeString(name)
writeByte(if (isHidden) 1 else 0)
writeByte(type)
@ -48,12 +79,10 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
if (other !is VolumeData) {
return false
}
return other.name == name && other.isHidden == isHidden
return other.uuid == uuid
}
override fun hashCode(): Int {
return name.hashCode()+isHidden.hashCode()
}
override fun hashCode() = uuid.hashCode()
companion object {
const val VOLUMES_DIRECTORY = "volumes"
@ -64,8 +93,17 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size)
}
fun newUuid(): String = UUID.randomUUID().toString()
fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name)
}
fun getFullPath(name: String, isHidden: Boolean, filesDir: String): String {
return if (isHidden)
getHiddenVolumeFullPath(filesDir, name)
else
name
}
}
}

View File

@ -10,46 +10,69 @@ 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, 5) {
class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Constants.VOLUME_DATABASE_NAME, null, 6) {
companion object {
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 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
}
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"
}
override fun onCreate(db: SQLiteDatabase) {
private fun createTable(db: SQLiteDatabase) =
db.execSQL(
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
"$COLUMN_NAME TEXT PRIMARY KEY," +
"$COLUMN_HIDDEN SHORT," +
"$COLUMN_TYPE BLOB," +
"$COLUMN_HASH BLOB," +
"$COLUMN_IV BLOB" +
");"
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
"$COLUMN_UUID TEXT PRIMARY KEY," +
"$COLUMN_NAME TEXT," +
"$COLUMN_HIDDEN SHORT," +
"$COLUMN_TYPE BLOB," +
"$COLUMN_HASH BLOB," +
"$COLUMN_IV BLOB" +
");"
)
override fun onCreate(db: SQLiteDatabase) {
createTable(db)
File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()
}
override fun onOpen(db: SQLiteDatabase) {
//check if database has been corrupted by v2.1.1
val cursor = db.rawQuery("SELECT * FROM $TABLE_NAME WHERE $COLUMN_TYPE IS NULL;", null)
if (cursor.count > 0) {
Log.w(TAG, "Found ${cursor.count} corrupted volumes")
while (cursor.moveToNext()) {
// fix columns left shift
val uuid = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)+5)
val name = cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)-1)
val isHidden = cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)-1) == 1.toShort()
val type = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE)-1)[0]
val hash = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)-1)
val iv = cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_IV)-1)
if (db.delete(TABLE_NAME, "$COLUMN_IV=?", arrayOf(uuid)) < 1) {
Log.e(TAG, "Failed to remove volume $name")
}
if (db.insert(TABLE_NAME, null, ContentValues().apply {
put(COLUMN_UUID, uuid)
put(COLUMN_NAME, name)
put(COLUMN_HIDDEN, isHidden)
put(COLUMN_TYPE, byteArrayOf(type))
put(COLUMN_HASH, hash)
put(COLUMN_IV, iv)
}) < 0) {
Log.e(TAG, "Failed to insert volume $name")
}
}
}
cursor.close()
}
private fun getNewVolumePath(volumeName: String): File {
return File(
VolumeData(
volumeName,
true,
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE
).getFullPath(context.filesDir.path)
VolumeData.getFullPath(volumeName, true, context.filesDir.path)
).canonicalFile
}
@ -101,10 +124,37 @@ 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],
@ -142,7 +192,14 @@ 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, contentValuesFromVolume(volume)) >= 0.toLong())
return (writableDatabase.insert(TABLE_NAME, null, ContentValues().apply {
put(COLUMN_UUID, volume.uuid)
put(COLUMN_NAME, volume.name)
put(COLUMN_HIDDEN, volume.isHidden)
put(COLUMN_TYPE, byteArrayOf(volume.type))
put(COLUMN_HASH, volume.encryptedHash)
put(COLUMN_IV, volume.iv)
}) >= 0.toLong())
}
return false
}
@ -157,8 +214,8 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
return list
}
fun isHashSaved(volumeName: String): Boolean {
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME, COLUMN_HASH), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null)
fun isHashSaved(volume: VolumeData): Boolean {
val cursor = readableDatabase.rawQuery("SELECT $COLUMN_HASH FROM $TABLE_NAME WHERE $COLUMN_UUID=?", arrayOf(volume.uuid))
var isHashSaved = false
if (cursor.moveToNext()) {
if (cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)) != null) {
@ -170,32 +227,33 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
}
fun addHash(volume: VolumeData): Boolean {
return writableDatabase.update(TABLE_NAME, contentValuesFromVolume(volume), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
return writableDatabase.update(TABLE_NAME, ContentValues().apply {
put(COLUMN_HASH, volume.encryptedHash)
put(COLUMN_IV, volume.iv)
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
}
fun removeHash(volume: VolumeData): Boolean {
return writableDatabase.update(
TABLE_NAME, contentValuesFromVolume(
VolumeData(
volume.name,
volume.isHidden,
volume.type,
null,
null
)
), "$COLUMN_NAME=?", arrayOf(volume.name)) > 0
}
fun renameVolume(oldName: String, newName: String): Boolean {
return writableDatabase.update(TABLE_NAME,
TABLE_NAME,
ContentValues().apply {
put(COLUMN_NAME, newName)
},
"$COLUMN_NAME=?",arrayOf(oldName)
put(COLUMN_HASH, null as ByteArray?)
put(COLUMN_IV, null as ByteArray?)
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)
) > 0
}
fun removeVolume(volumeName: String): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_NAME=?", arrayOf(volumeName)) > 0
fun renameVolume(volume: VolumeData, newName: String): Boolean {
return writableDatabase.update(
TABLE_NAME,
ContentValues().apply {
put(COLUMN_NAME, newName)
},
"$COLUMN_UUID=?", arrayOf(volume.uuid)
) > 0
}
fun removeVolume(volume: VolumeData): Boolean {
return writableDatabase.delete(TABLE_NAME, "$COLUMN_UUID=?", arrayOf(volume.uuid)) > 0
}
}

View File

@ -1,15 +1,23 @@
package sushi.hardcore.droidfs
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
class VolumeManager {
class VolumeManager(private val context: Context) {
private var id = 0
private val volumes = HashMap<Int, EncryptedVolume>()
private val volumesData = HashMap<VolumeData, Int>()
private val scopes = HashMap<Int, CoroutineScope>()
fun insert(volume: EncryptedVolume, data: VolumeData): Int {
volumes[id] = volume
volumesData[data] = id
VolumeProvider.notifyRootsChanged(context)
return id++
}
@ -25,18 +33,32 @@ class VolumeManager {
return volumes[id]
}
fun listVolumes(): List<Pair<Int, VolumeData>> {
return volumesData.map { (data, id) -> Pair(id, data) }
}
fun getCoroutineScope(volumeId: Int): CoroutineScope {
return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it }
}
fun closeVolume(id: Int) {
volumes.remove(id)?.let { volume ->
scopes[id]?.cancel()
volume.close()
volumesData.filter { it.value == id }.forEach {
volumesData.remove(it.key)
}
VolumeProvider.notifyRootsChanged(context)
}
}
fun closeAll() {
volumes.forEach { it.value.close() }
volumes.forEach {
scopes[it.key]?.cancel()
it.value.close()
}
volumes.clear()
volumesData.clear()
VolumeProvider.notifyRootsChanged(context)
}
}

View File

@ -6,7 +6,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.preference.PreferenceManager
import sushi.hardcore.droidfs.content_providers.RestrictedFileProvider
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
class VolumeManagerApp : Application(), DefaultLifecycleObserver {
companion object {
@ -20,8 +20,9 @@ class VolumeManagerApp : Application(), DefaultLifecycleObserver {
}
}
private var usfKeepOpen = false
var isExporting = false
var isStartingExternalApp = false
val volumeManager = VolumeManager()
val volumeManager = VolumeManager(this)
override fun onCreate() {
super<Application>.onCreate()
@ -45,7 +46,9 @@ class VolumeManagerApp : Application(), DefaultLifecycleObserver {
if (!usfKeepOpen) {
volumeManager.closeAll()
}
RestrictedFileProvider.wipeAll(applicationContext)
if (!usfKeepOpen || !isExporting) {
TemporaryFileProvider.instance.wipe()
}
}
}
}

View File

@ -13,7 +13,7 @@ import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.WidgetUtil
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.util.*
@ -39,6 +39,14 @@ class VolumeOpener(
}
}
private fun getErrorMsg(result: EncryptedVolume.InitResult): String {
return if (result.errorStringId == 0) {
activity.getString(R.string.unknown_error_code, result.errorCode)
} else {
activity.getString(result.errorStringId)
}
}
@SuppressLint("NewApi") // fingerprintProtector is non-null only when SDK_INT >= 23
fun openVolume(volume: VolumeData, isVolumeSaved: Boolean, callbacks: VolumeOpenerCallbacks) {
val volumeId = volumeManager.getVolumeId(volume)
@ -60,17 +68,18 @@ class VolumeOpener(
callbacks.onHashStorageReset()
}
override fun onPasswordHashDecrypted(hash: ByteArray) {
object : LoadingTask<EncryptedVolume?>(activity, theme, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume? {
val encryptedVolume = EncryptedVolume.init(volume, activity.filesDir.path, null, hash, null)
object : LoadingTask<EncryptedVolume.InitResult>(activity, theme, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume.InitResult {
val result = EncryptedVolume.init(volume, activity.filesDir.path, null, hash, null)
Arrays.fill(hash, 0)
return encryptedVolume
return result
}
}.startTask(activity.lifecycleScope) { encryptedVolume ->
}.startTask(activity.lifecycleScope) { result ->
val encryptedVolume = result.volume
if (encryptedVolume == null) {
CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.open_volume_failed)
.setMessage(R.string.open_failed_hash_msg)
.setMessage(getErrorMsg(result))
.setPositiveButton(R.string.ok, null)
.show()
} else {
@ -114,7 +123,7 @@ class VolumeOpener(
apply()
}
}
val password = WidgetUtil.encodeEditTextContent(dialogBinding!!.editPassword)
val password = UIUtils.encodeEditTextContent(dialogBinding!!.editPassword)
val savePasswordHash = dialogBinding!!.checkboxSavePassword.isChecked
dialogBinding = null
// openVolumeWithPassword is responsible for wiping the password
@ -168,19 +177,22 @@ class VolumeOpener(
} else {
null
}
object : LoadingTask<EncryptedVolume?>(activity, theme, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume? {
val encryptedVolume = EncryptedVolume.init(volume, activity.filesDir.path, password, null, returnedHash)
object : LoadingTask<EncryptedVolume.InitResult>(activity, theme, R.string.loading_msg_open) {
override suspend fun doTask(): EncryptedVolume.InitResult {
val result = EncryptedVolume.init(volume, activity.filesDir.path, password, null, returnedHash)
Arrays.fill(password, 0)
return encryptedVolume
return result
}
}.startTask(activity.lifecycleScope) { encryptedVolume ->
}.startTask(activity.lifecycleScope) { result ->
val encryptedVolume = result.volume
if (encryptedVolume == null) {
CustomAlertDialogBuilder(activity, theme)
.setTitle(R.string.open_volume_failed)
.setMessage(R.string.open_volume_failed_msg)
.setMessage(getErrorMsg(result))
.setPositiveButton(R.string.ok) { _, _ ->
askForPassword(volume, isVolumeSaved, callbacks, savePasswordHash)
if (result.worthRetry) {
askForPassword(volume, isVolumeSaved, callbacks, savePasswordHash)
}
}
.show()
} else {

View File

@ -2,7 +2,6 @@ package sushi.hardcore.droidfs.adapters
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.util.LruCache
import android.view.View
import android.view.ViewGroup
@ -11,13 +10,12 @@ import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.bumptech.glide.request.target.DrawableImageViewTarget
import com.bumptech.glide.request.transition.Transition
import kotlinx.coroutines.*
import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.ThumbnailsLoader
import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
@ -29,7 +27,7 @@ class ExplorerElementAdapter(
val activity: AppCompatActivity,
val encryptedVolume: EncryptedVolume?,
private val listener: Listener,
val thumbnailMaxSize: Long,
thumbnailMaxSize: Long,
) : SelectableAdapter<ExplorerElement>(listener::onSelectionChanged) {
val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.getDefault())
var explorerElements = listOf<ExplorerElement>()
@ -40,12 +38,18 @@ class ExplorerElementAdapter(
notifyDataSetChanged()
}
var isUsingListLayout = true
private var thumbnailsLoader: ThumbnailsLoader? = null
private var thumbnailsCache: LruCache<String, Bitmap>? = null
var loadThumbnails = true
init {
if (encryptedVolume != null) {
thumbnailsCache = LruCache((Runtime.getRuntime().maxMemory() / 1024 / 8).toInt())
thumbnailsLoader = ThumbnailsLoader(activity, encryptedVolume, thumbnailMaxSize, activity.lifecycleScope).apply {
initialize()
}
thumbnailsCache = object : LruCache<String, Bitmap>((Runtime.getRuntime().maxMemory() / 4).toInt()) {
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
}
}
}
@ -115,40 +119,11 @@ class ExplorerElementAdapter(
}
class FileViewHolder(itemView: View) : RegularElementViewHolder(itemView) {
private var target: DrawableImageViewTarget? = null
private var job: Job? = null
private val scope = CoroutineScope(Dispatchers.IO)
private fun loadThumbnail(fullPath: String, adapter: ExplorerElementAdapter) {
adapter.encryptedVolume?.let { volume ->
job = scope.launch {
volume.loadWholeFile(fullPath, maxSize = adapter.thumbnailMaxSize).first?.let {
if (isActive) {
withContext(Dispatchers.Main) {
if (isActive && !adapter.activity.isFinishing && !adapter.activity.isDestroyed) {
target = Glide.with(adapter.activity).load(it).skipMemoryCache(true).into(object : DrawableImageViewTarget(icon) {
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable>?
) {
target = null
val bitmap = resource.toBitmap()
adapter.thumbnailsCache!!.put(fullPath, bitmap.copy(bitmap.config, true))
super.onResourceReady(resource, transition)
}
})
}
}
}
}
}
}
}
private var task = -1
fun cancelThumbnailLoading(adapter: ExplorerElementAdapter) {
job?.cancel()
target?.let {
Glide.with(adapter.activity).clear(it)
if (task != -1) {
adapter.thumbnailsLoader?.cancel(task)
}
}
@ -161,7 +136,10 @@ class ExplorerElementAdapter(
icon.setImageBitmap(thumbnail)
setDefaultIcon = false
} else if (adapter.loadThumbnails) {
loadThumbnail(fullPath, adapter)
task = adapter.thumbnailsLoader!!.loadAsync(fullPath, icon) { resource ->
val bitmap = resource.toBitmap()
adapter.thumbnailsCache!!.put(fullPath, bitmap.copy(bitmap.config, true))
}
}
}
}

View File

@ -92,8 +92,10 @@ class VolumeAdapter(
itemView.findViewById<TextView>(R.id.text_info).text = context.getString(
if (volume.canWrite(context.filesDir.path)) {
R.string.volume_type
} else {
} else if (volume.canRead(context.filesDir.path)) {
R.string.volume_type_read_only
} else {
R.string.volume_type_inaccessible
},
context.getString(if (volume.type == EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) {
R.string.gocryptfs

View File

@ -20,7 +20,7 @@ import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.Compat
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.WidgetUtil
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File
import java.util.*
@ -146,8 +146,8 @@ class CreateVolumeFragment: Fragment() {
}
private fun createVolume() {
val password = WidgetUtil.encodeEditTextContent(binding.editPassword)
val passwordConfirm = WidgetUtil.encodeEditTextContent(binding.editPasswordConfirm)
val password = UIUtils.encodeEditTextContent(binding.editPassword)
val passwordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
if (!password.contentEquals(passwordConfirm)) {
Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
Arrays.fill(password, 0)
@ -190,14 +190,14 @@ class CreateVolumeFragment: Fragment() {
encryptedVolume,
), EncryptedVolume.GOCRYPTFS_VOLUME_TYPE)
} else {
generateResult(CryfsVolume.create(
encryptedVolume.value = CryfsVolume.create(
volumePath,
CryfsVolume.getLocalStateDir(activity.filesDir.path),
password,
returnedHash,
resources.getStringArray(R.array.cryfs_encryption_ciphers)[binding.spinnerCipher.selectedItemPosition],
encryptedVolume,
), EncryptedVolume.CRYFS_VOLUME_TYPE)
)
generateResult(encryptedVolume.value != null, EncryptedVolume.CRYFS_VOLUME_TYPE)
}
Arrays.fill(password, 0)
return result
@ -211,11 +211,11 @@ class CreateVolumeFragment: Fragment() {
.show()
} else {
val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath
val volume = VolumeData(volumeName, isHiddenVolume, result)
val volume = VolumeData(VolumeData.newUuid(), volumeName, isHiddenVolume, result)
var isVolumeSaved = false
volumeDatabase.apply {
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
removeVolume(volumeName)
removeVolume(volume)
if (rememberVolume) {
isVolumeSaved = saveVolume(volume)
}
@ -256,7 +256,7 @@ class CreateVolumeFragment: Fragment() {
private fun onVolumeCreated(id: Int?, volumeShortName: String) {
(activity as AddVolumeActivity).apply {
if (rememberVolume || id == null) {
if (id == null) {
finish()
} else {
startExplorer(id, volumeShortName)

View File

@ -350,7 +350,7 @@ class SelectPathFragment: Fragment() {
}
private fun addVolume(volumeName: String, isHidden: Boolean, volumeType: Byte) {
val volumeData = VolumeData(volumeName, isHidden, volumeType)
val volumeData = VolumeData(VolumeData.newUuid(), volumeName, isHidden, volumeType)
if (binding.switchRemember.isChecked) {
volumeDatabase.saveVolume(volumeData)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -11,10 +11,12 @@ import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ImageButton
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.addCallback
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
@ -22,17 +24,37 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlinx.coroutines.*
import sushi.hardcore.droidfs.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.FileShare
import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter
import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
import sushi.hardcore.droidfs.content_providers.ExternalProvider
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.file_viewers.*
import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.file_viewers.AudioPlayer
import sushi.hardcore.droidfs.file_viewers.ImageViewer
import sushi.hardcore.droidfs.file_viewers.PdfViewer
import sushi.hardcore.droidfs.file_viewers.TextEditor
import sushi.hardcore.droidfs.file_viewers.VideoPlayer
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.util.UIUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -42,7 +64,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
private var foldersFirst = true
private var mapFolders = true
private var currentSortOrderIndex = 0
private var volumeId = -1
protected var volumeId = -1
protected lateinit var encryptedVolume: EncryptedVolume
private lateinit var volumeName: String
private lateinit var explorerViewModel: ExplorerViewModel
@ -52,7 +74,8 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
explorerViewModel.currentDirectoryPath = value
}
protected lateinit var fileOperationService: FileOperationService
protected val taskScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
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
@ -63,11 +86,13 @@ 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
private lateinit var numberOfFoldersText: TextView
private lateinit var totalSizeText: TextView
protected val fileShare by lazy { FileShare(this) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -84,6 +109,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
init()
recycler_view_explorer = findViewById(R.id.recycler_view_explorer)
refresher = findViewById(R.id.refresher)
loader = findViewById(R.id.loader)
textDirEmpty = findViewById(R.id.text_dir_empty)
currentPathText = findViewById(R.id.current_path_text)
numberOfFilesText = findViewById(R.id.number_of_files_text)
@ -108,7 +134,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
)
explorerViewModel = ViewModelProvider(this).get(ExplorerViewModel::class.java)
currentDirectoryPath = explorerViewModel.currentDirectoryPath
setCurrentPath(currentDirectoryPath)
linearLayoutManager = LinearLayoutManager(this@BaseExplorerActivity)
recycler_view_explorer.adapter = explorerAdapter
isUsingListLayout = sharedPrefs.getBoolean("useListLayout", true)
@ -171,11 +196,8 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as FileOperationService.LocalBinder
fileOperationService = binder.getService()
binder.setEncryptedVolume(encryptedVolume)
}
override fun onServiceDisconnected(arg0: ComponentName) {
}
override fun onServiceDisconnected(arg0: ComponentName) {}
}, Context.BIND_AUTO_CREATE)
}
}
@ -189,12 +211,43 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
startActivity(intent)
}
private fun openWithExternalApp(fullPath: String) {
app.isStartingExternalApp = true
ExternalProvider.open(this, theme, encryptedVolume, fullPath)
protected fun onExportFailed(errorResId: Int) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.tmp_export_failed, getString(errorResId)))
.setPositiveButton(R.string.ok, null)
.show()
}
private fun showOpenAsDialog(path: String) {
private fun openWithExternalApp(path: String, size: Long) {
app.isExporting = true
val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
if (exportedFile == null) {
onExportFailed(R.string.export_failed_create)
return
}
val msg = when (exportedFile) {
is EncryptedFileProvider.ExportedMemFile -> R.string.export_mem
is EncryptedFileProvider.ExportedDiskFile -> R.string.export_disk
else -> R.string.loading_msg_export
}
object : LoadingTask<Pair<Intent?, Int?>>(this, theme, msg) {
override suspend fun doTask(): Pair<Intent?, Int?> {
return fileShare.openWith(exportedFile, size, volumeId)
}
}.startTask(lifecycleScope) { (intent, error) ->
if (intent == null) {
onExportFailed(error!!)
} else {
app.isStartingExternalApp = true
startActivity(intent)
}
app.isExporting = false
}
}
private fun showOpenAsDialog(explorerElement: ExplorerElement) {
val path = explorerElement.fullPath
val adapter = OpenAsDialogAdapter(this, usf_open)
CustomAlertDialogBuilder(this, theme)
.setSingleChoiceItems(adapter, -1) { dialog, which ->
@ -205,7 +258,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
"pdf" -> startFileViewer(PdfViewer::class.java, path)
"text" -> startFileViewer(TextEditor::class.java, path)
"external" -> if (usf_open) {
openWithExternalApp(path)
openWithExternalApp(path, explorerElement.stat.size)
}
}
dialog.dismiss()
@ -252,7 +305,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
FileTypes.isAudio(fullPath) -> {
startFileViewer(AudioPlayer::class.java, fullPath)
}
else -> showOpenAsDialog(fullPath)
else -> showOpenAsDialog(explorerElements[position])
}
}
invalidateOptionsMenu()
@ -267,19 +320,16 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
invalidateOptionsMenu()
}
private fun displayExplorerElements(totalSize: Long) {
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
synchronized(this) {
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
}
private fun displayExplorerElements() {
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
unselectAll(false)
loader.isVisible = false
recycler_view_explorer.isVisible = true
explorerAdapter.explorerElements = explorerElements
val sharedPrefsEditor = sharedPrefs.edit()
sharedPrefsEditor.putString(Constants.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
sharedPrefsEditor.apply()
}
private fun recursiveSetSize(directory: ExplorerElement) {
private suspend fun recursiveSetSize(directory: ExplorerElement) {
yield()
for (child in encryptedVolume.readDir(directory.fullPath) ?: return) {
if (child.isDirectory) {
recursiveSetSize(child)
@ -303,15 +353,16 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) {
synchronized(this) {
explorerElements = encryptedVolume.readDir(path) ?: return
if (path != "/") {
explorerElements.add(
0,
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
)
}
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) = lifecycleScope.launch {
directoryLoadingTask?.cancelAndJoin()
recycler_view_explorer.isVisible = false
loader.isVisible = true
explorerElements = encryptedVolume.readDir(path) ?: return@launch
if (path != "/") {
explorerElements.add(
0,
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
)
}
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.GONE
currentDirectoryPath = path
@ -319,23 +370,25 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
displayNumberOfElements(numberOfFilesText, R.string.one_file, R.string.multiple_files, explorerElements.count { it.isRegularFile })
displayNumberOfElements(numberOfFoldersText, R.string.one_folder, R.string.multiple_folders, explorerElements.count { it.isDirectory })
if (mapFolders) {
lifecycleScope.launch {
var totalSize: Long = 0
withContext(Dispatchers.IO) {
synchronized(this@BaseExplorerActivity) {
for (element in explorerElements) {
if (element.isDirectory) {
recursiveSetSize(element)
}
totalSize += element.stat.size
}
var totalSize: Long = 0
directoryLoadingTask = launch(Dispatchers.IO) {
for (element in explorerElements) {
if (element.isDirectory) {
recursiveSetSize(element)
}
totalSize += element.stat.size
}
displayExplorerElements(totalSize)
onDisplayed?.invoke()
}
directoryLoadingTask!!.join()
displayExplorerElements()
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
onDisplayed?.invoke()
} else {
displayExplorerElements(explorerElements.filter { !it.isParentFolder }.sumOf { it.stat.size })
displayExplorerElements()
totalSizeText.text = getString(
R.string.total_size,
PathUtils.formatSize(explorerElements.filter { !it.isParentFolder }.sumOf { it.stat.size })
)
onDisplayed?.invoke()
}
}
@ -375,10 +428,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}.show()
}
protected fun checkPathOverwrite(items: ArrayList<OperationFile>, dstDirectoryPath: String, callback: (ArrayList<OperationFile>?) -> Unit) {
protected fun checkPathOverwrite(items: List<OperationFile>, dstDirectoryPath: String, callback: (List<OperationFile>?) -> Unit) {
val srcDirectoryPath = items[0].parentPath
var ready = true
for (i in 0 until items.size) {
for (i in items.indices) {
val testDstPath: String
if (items[i].dstPath == null){
testDstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].srcPath))
@ -412,7 +465,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
with(EditTextDialog(this, R.string.enter_new_name) {
items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].parentPath), it)
if (items[i].isDirectory) {
for (j in 0 until items.size){
for (j in items.indices) {
if (PathUtils.isChildOf(items[j].srcPath, items[i].srcPath)) {
items[j].dstPath = PathUtils.pathJoin(items[i].dstPath!!, PathUtils.getRelativePath(items[i].srcPath, items[j].srcPath))
}
@ -439,7 +492,33 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
protected fun importFilesFromUris(uris: List<Uri>, callback: (String?) -> Unit) {
protected fun onTaskResult(
result: TaskResult<out String?>,
failedErrorMessage: Int,
successMessage: Int = -1,
onSuccess: (() -> Unit)? = null,
) {
when (result.state) {
TaskResult.State.SUCCESS -> {
if (onSuccess == null) {
Toast.makeText(this, successMessage, Toast.LENGTH_SHORT).show()
} else {
onSuccess()
}
}
TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(failedErrorMessage, result.failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
TaskResult.State.ERROR -> result.showErrorAlertDialog(this, theme)
TaskResult.State.CANCELLED -> {}
}
}
protected fun importFilesFromUris(uris: List<Uri>, callback: () -> Unit) {
val items = ArrayList<OperationFile>()
for (uri in uris) {
val fileName = PathUtils.getFilenameFromURI(this, uri)
@ -458,13 +537,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
if (items.size > 0) {
checkPathOverwrite(items, currentDirectoryPath) { checkedItems ->
checkedItems?.let {
taskScope.launch {
val taskResult = fileOperationService.importFilesFromUris(checkedItems.map { it.dstPath!! }, uris)
if (taskResult.cancelled) {
setCurrentPath(currentDirectoryPath)
} else {
callback(taskResult.failedItem)
}
activityScope.launch {
val result = fileOperationService.importFilesFromUris(volumeId, checkedItems.map { it.dstPath!! }, uris)
onTaskResult(result, R.string.import_failed, onSuccess = callback)
setCurrentPath(currentDirectoryPath)
}
}
}
@ -489,14 +565,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}
}
private fun setMenuIconTint(menu: Menu, iconColor: Int, menuItemId: Int, drawableId: Int) {
menu.findItem(menuItemId)?.let {
it.icon = ContextCompat.getDrawable(this, drawableId)?.apply {
setTint(iconColor)
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menu.findItem(R.id.rename).isVisible = false
menu.findItem(R.id.open_as)?.isVisible = false
@ -504,9 +572,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
menu.findItem(R.id.external_open)?.isVisible = false
}
val noItemSelected = explorerAdapter.selectedItems.isEmpty()
val iconColor = ContextCompat.getColor(this, R.color.neutralIconTint)
setMenuIconTint(menu, iconColor, R.id.sort, R.drawable.icon_sort)
setMenuIconTint(menu, iconColor, R.id.share, R.drawable.icon_share)
with(UIUtils.getMenuIconNeutralTint(this, menu)) {
applyTo(R.id.sort, R.drawable.icon_sort)
applyTo(R.id.share, R.drawable.icon_share)
}
menu.findItem(R.id.sort).isVisible = noItemSelected
menu.findItem(R.id.lock).isVisible = noItemSelected
menu.findItem(R.id.close).isVisible = noItemSelected
@ -536,7 +605,13 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
.setTitle(R.string.sort_order)
.setSingleChoiceItems(sortOrderEntries, currentSortOrderIndex) { dialog, which ->
currentSortOrderIndex = which
setCurrentPath(currentDirectoryPath)
// displayExplorerElements must not be called if directoryLoadingTask is active
if (directoryLoadingTask?.isActive != true) {
displayExplorerElements()
}
val sharedPrefsEditor = sharedPrefs.edit()
sharedPrefsEditor.putString(Constants.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
sharedPrefsEditor.apply()
dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
@ -554,22 +629,13 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
true
}
R.id.open_as -> {
showOpenAsDialog(
PathUtils.pathJoin(
currentDirectoryPath,
explorerElements[explorerAdapter.selectedItems.first()].name
)
)
showOpenAsDialog(explorerElements[explorerAdapter.selectedItems.first()])
true
}
R.id.external_open -> {
if (usf_open){
openWithExternalApp(
PathUtils.pathJoin(
currentDirectoryPath,
explorerElements[explorerAdapter.selectedItems.first()].name
)
)
val explorerElement = explorerElements[explorerAdapter.selectedItems.first()]
openWithExternalApp(explorerElement.fullPath, explorerElement.stat.size)
unselectAll()
}
true
@ -589,14 +655,14 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
override fun onDestroy() {
super.onDestroy()
if (!isChangingConfigurations) { //activity won't be recreated
taskScope.cancel()
activityScope.cancel()
}
}
override fun onResume() {
super.onResume()
if (app.isStartingExternalApp) {
ExternalProvider.removeFilesAsync(this)
TemporaryFileProvider.instance.wipe()
}
if (encryptedVolume.isClosed()) {
finish()

View File

@ -9,17 +9,16 @@ import android.widget.Toast
import androidx.activity.addCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.lifecycleScope
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.launch
import sushi.hardcore.droidfs.CameraActivity
import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.MainActivity
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.adapters.IconTextDialogAdapter
import sushi.hardcore.droidfs.content_providers.ExternalProvider
import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.Stat
import sushi.hardcore.droidfs.util.IntentUtils
import sushi.hardcore.droidfs.util.PathUtils
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import sushi.hardcore.droidfs.widgets.EditTextDialog
@ -36,131 +35,115 @@ class ExplorerActivity : BaseExplorerActivity() {
private val pickFromOtherVolumes = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
result.data?.let { resultIntent ->
val remoteEncryptedVolume = IntentUtils.getParcelableExtra<EncryptedVolume>(resultIntent, "volume")!!
val srcVolumeId = resultIntent.getIntExtra("volumeId", -1)
val srcEncryptedVolume = app.volumeManager.getVolume(srcVolumeId)!!
val path = resultIntent.getStringExtra("path")
val operationFiles = ArrayList<OperationFile>()
if (path == null){ //multiples elements
val paths = resultIntent.getStringArrayListExtra("paths")
val types = resultIntent.getIntegerArrayListExtra("types")
if (types != null && paths != null){
for (i in paths.indices) {
operationFiles.add(
OperationFile(paths[i], types[i])
)
if (types[i] == Stat.S_IFDIR) {
remoteEncryptedVolume.recursiveMapFiles(paths[i])?.forEach {
operationFiles.add(OperationFile.fromExplorerElement(it))
object : LoadingTask<List<OperationFile>>(this, theme, R.string.discovering_files) {
override suspend fun doTask(): List<OperationFile> {
val operationFiles = ArrayList<OperationFile>()
for (i in paths.indices) {
operationFiles.add(OperationFile(paths[i], types[i]))
if (types[i] == Stat.S_IFDIR) {
srcEncryptedVolume.recursiveMapFiles(paths[i])?.forEach {
operationFiles.add(OperationFile.fromExplorerElement(it))
}
}
}
return operationFiles
}
}.startTask(lifecycleScope) { operationFiles ->
importFilesFromVolume(srcVolumeId, operationFiles)
}
}
} else {
operationFiles.add(
OperationFile(path, Stat.S_IFREG)
)
}
if (operationFiles.size > 0){
checkPathOverwrite(operationFiles, currentDirectoryPath) { items ->
if (items != null) {
// stop loading thumbnails while writing files
explorerAdapter.loadThumbnails = false
taskScope.launch {
val failedItem = fileOperationService.copyElements(items, remoteEncryptedVolume)
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.success_import, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
explorerAdapter.loadThumbnails = true
setCurrentPath(currentDirectoryPath)
}
}
}
importFilesFromVolume(srcVolumeId, arrayListOf(OperationFile(path, Stat.S_IFREG)))
}
}
}
}
private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
if (uris != null) {
importFilesFromUris(uris){ failedItem ->
onImportComplete(failedItem, uris)
for (uri in uris) {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
importFilesFromUris(uris) {
onImportComplete(uris)
}
}
}
private val pickExportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
taskScope.launch {
val result = fileOperationService.exportFiles(uri, explorerAdapter.selectedItems.map { i -> explorerElements[i] })
if (!result.cancelled) {
if (result.failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.success_export, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.export_failed, result.failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
}
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
activityScope.launch {
val result = fileOperationService.exportFiles(volumeId, items, uri)
onTaskResult(result, R.string.export_failed, R.string.success_export)
}
}
unselectAll()
}
private val pickImportDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { rootUri ->
rootUri?.let {
contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
val tree = DocumentFile.fromTreeUri(this, it)!! //non-null after Lollipop
val operation = OperationFile(PathUtils.pathJoin(currentDirectoryPath, tree.name!!), Stat.S_IFDIR)
checkPathOverwrite(arrayListOf(operation), currentDirectoryPath) { checkedOperation ->
checkedOperation?.let {
taskScope.launch {
val result = fileOperationService.importDirectory(checkedOperation[0].dstPath!!, tree)
if (result.taskResult.cancelled) {
setCurrentPath(currentDirectoryPath)
} else {
onImportComplete(result.taskResult.failedItem, result.uris, tree)
activityScope.launch {
val result = fileOperationService.importDirectory(volumeId, checkedOperation[0].dstPath!!, tree)
onTaskResult(result.taskResult, R.string.import_failed) {
onImportComplete(result.uris, tree)
}
setCurrentPath(currentDirectoryPath)
}
}
}
}
}
private fun onImportComplete(failedItem: String?, urisToWipe: List<Uri>, rootFile: DocumentFile? = null) {
if (failedItem == null){
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage("""
${getString(R.string.success_import_msg)}
${getString(R.string.ask_for_wipe)}
""".trimIndent())
.setPositiveButton(R.string.yes) { _, _ ->
taskScope.launch {
val errorMsg = fileOperationService.wipeUris(urisToWipe, rootFile)
if (errorMsg == null) {
Toast.makeText(this@ExplorerActivity, R.string.wipe_successful, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.wipe_failed, errorMsg))
.setPositiveButton(R.string.ok, null)
.show()
}
}
private fun importFilesFromVolume(srcVolumeId: Int, operationFiles: List<OperationFile>) {
checkPathOverwrite(operationFiles, currentDirectoryPath) { items ->
if (items != null) {
// stop loading thumbnails while writing files
explorerAdapter.loadThumbnails = false
activityScope.launch {
onTaskResult(
fileOperationService.copyElements(
volumeId,
items,
srcVolumeId
), R.string.import_failed, R.string.success_import
)
explorerAdapter.loadThumbnails = true
setCurrentPath(currentDirectoryPath)
}
.setNegativeButton(R.string.no, null)
.show()
} else {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
}
setCurrentPath(currentDirectoryPath)
}
private fun onImportComplete(urisToWipe: List<Uri>, rootFile: DocumentFile? = null) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage("""
${getString(R.string.success_import_msg)}
${getString(R.string.ask_for_wipe)}
""".trimIndent())
.setPositiveButton(R.string.yes) { _, _ ->
activityScope.launch {
onTaskResult(
fileOperationService.wipeUris(urisToWipe, rootFile),
R.string.wipe_failed,
R.string.wipe_successful,
)
}
}
.setNegativeButton(R.string.no, null)
.show()
}
override fun init() {
@ -181,7 +164,7 @@ class ExplorerActivity : BaseExplorerActivity() {
} else {
val adapter = IconTextDialogAdapter(this)
adapter.items = listOf(
listOf("importFromOtherVolumes", R.string.import_from_other_volume, R.drawable.icon_transfert),
listOf("importFromOtherVolumes", R.string.import_from_other_volume, R.drawable.icon_transfer),
listOf("importFiles", R.string.import_files, R.drawable.icon_encrypt),
listOf("importFolder", R.string.import_folder, R.drawable.icon_import_folder),
listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown),
@ -312,11 +295,6 @@ class ExplorerActivity : BaseExplorerActivity() {
R.id.copy -> {
for (i in explorerAdapter.selectedItems){
itemsToProcess.add(OperationFile.fromExplorerElement(explorerElements[i]))
if (explorerElements[i].isDirectory){
encryptedVolume.recursiveMapFiles(explorerElements[i].fullPath)?.forEach {
itemsToProcess.add(OperationFile.fromExplorerElement(it))
}
}
}
currentItemAction = ItemsActions.COPY
unselectAll()
@ -324,26 +302,31 @@ class ExplorerActivity : BaseExplorerActivity() {
}
R.id.validate -> {
if (currentItemAction == ItemsActions.COPY){
checkPathOverwrite(itemsToProcess, currentDirectoryPath){ items ->
items?.let {
taskScope.launch {
val failedItem = fileOperationService.copyElements(it.toMutableList() as ArrayList<OperationFile>)
if (!isFinishing) {
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.copy_success, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
object : LoadingTask<List<OperationFile>>(this, theme, R.string.discovering_files) {
override suspend fun doTask(): List<OperationFile> {
val items = itemsToProcess.toMutableList()
itemsToProcess.filter { it.isDirectory }.forEach { dir ->
encryptedVolume.recursiveMapFiles(dir.srcPath)?.forEach {
items.add(OperationFile.fromExplorerElement(it))
}
}
return items
}
}.startTask(lifecycleScope) { items ->
checkPathOverwrite(items, currentDirectoryPath) {
it?.let { checkedItems ->
activityScope.launch {
onTaskResult(
fileOperationService.copyElements(volumeId, checkedItems),
R.string.copy_failed,
R.string.copy_success,
)
setCurrentPath(currentDirectoryPath)
}
}
cancelItemAction()
invalidateOptionsMenu()
}
cancelItemAction()
invalidateOptionsMenu()
}
} else if (currentItemAction == ItemsActions.MOVE){
itemsToProcess.forEach {
@ -357,17 +340,12 @@ class ExplorerActivity : BaseExplorerActivity() {
toMove,
toClean,
) {
taskScope.launch {
val failedItem = fileOperationService.moveElements(toMove, toClean)
if (failedItem == null) {
Toast.makeText(this@ExplorerActivity, R.string.move_success, Toast.LENGTH_SHORT).show()
} else {
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.move_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
activityScope.launch {
onTaskResult(
fileOperationService.moveElements(volumeId, toMove, toClean),
R.string.move_success,
R.string.move_failed,
)
setCurrentPath(currentDirectoryPath)
}
cancelItemAction()
@ -381,8 +359,9 @@ class ExplorerActivity : BaseExplorerActivity() {
val dialog = CustomAlertDialogBuilder(this, theme)
dialog.setTitle(R.string.warning)
dialog.setPositiveButton(R.string.ok) { _, _ ->
taskScope.launch {
fileOperationService.removeElements(explorerAdapter.selectedItems.map { i -> explorerElements[i] })?.let { failedItem ->
val items = explorerAdapter.selectedItems.map { i -> explorerElements[i] }
activityScope.launch {
fileOperationService.removeElements(volumeId, items)?.let { failedItem ->
CustomAlertDialogBuilder(this@ExplorerActivity, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.remove_failed, failedItem))
@ -406,12 +385,25 @@ class ExplorerActivity : BaseExplorerActivity() {
true
}
R.id.share -> {
val paths: MutableList<String> = ArrayList()
for (i in explorerAdapter.selectedItems) {
paths.add(explorerElements[i].fullPath)
val files = explorerAdapter.selectedItems.map { i ->
explorerElements[i].let {
Pair(it.fullPath, it.stat.size)
}
}
app.isExporting = true
object : LoadingTask<Pair<Intent?, Int?>>(this, theme, R.string.loading_msg_export) {
override suspend fun doTask(): Pair<Intent?, Int?> {
return fileShare.share(files, volumeId)
}
}.startTask(lifecycleScope) { (intent, error) ->
if (intent == null) {
onExportFailed(error!!)
} else {
app.isStartingExternalApp = true
startActivity(Intent.createChooser(intent, getString(R.string.share_chooser)))
}
app.isExporting = false
}
app.isStartingExternalApp = true
ExternalProvider.share(this, theme, encryptedVolume, paths)
unselectAll()
true
}

View File

@ -73,23 +73,15 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
}
}
private fun onImported(failedItem: String?){
private fun onImported() {
setCurrentPath(currentDirectoryPath)
if (failedItem == null) {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage(R.string.success_import_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok){_, _ ->
finish()
}
.show()
} else {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(R.string.import_failed, failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.success_import)
.setMessage(R.string.success_import_msg)
.setCancelable(false)
.setPositiveButton(R.string.ok) { _, _ ->
finish()
}
.show()
}
}

View File

@ -14,7 +14,7 @@ class ExplorerActivityPick : BaseExplorerActivity() {
private var isFinishingIntentionally = false
override fun init() {
setContentView(R.layout.activity_explorer_pick)
resultIntent.putExtra("volume", encryptedVolume)
resultIntent.putExtra("volumeId", volumeId)
}
override fun bindFileOperationService() {

View File

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

View File

@ -2,7 +2,6 @@ package sushi.hardcore.droidfs.explorers
import android.content.Context
import android.content.Intent
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.util.IntentUtils
class ExplorerRouter(private val context: Context, private val intent: Intent) {
@ -16,7 +15,6 @@ class ExplorerRouter(private val context: Context, private val intent: Intent) {
IntentUtils.forwardIntent(intent, explorerIntent)
} else if (pickMode) {
explorerIntent = Intent(context, ExplorerActivityPick::class.java)
explorerIntent.putExtra("destinationVolume", IntentUtils.getParcelableExtra<EncryptedVolume>(intent, "volume")!!)
explorerIntent.flags = Intent.FLAG_ACTIVITY_FORWARD_RESULT
}
if (explorerIntent == null) {

View File

@ -13,9 +13,20 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.*
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.withContext
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.Constants
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.util.ObjRef
@ -31,19 +42,18 @@ class FileOperationService : Service() {
}
private val binder = LocalBinder()
private lateinit var encryptedVolume: EncryptedVolume
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
fun setEncryptedVolume(volume: EncryptedVolume) {
encryptedVolume = volume
}
}
override fun onBind(p0: Intent?): IBinder {
volumeManger = (application as VolumeManagerApp).volumeManager
return binder
}
@ -110,29 +120,57 @@ class FileOperationService : Service() {
tasks[notificationId]?.cancel()
}
class TaskResult<T>(val cancelled: Boolean, val failedItem: T?)
private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
}
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<T> {
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<out T> {
tasks[notification.notificationId] = task
return try {
TaskResult(false, task.await())
} catch (e: CancellationException) {
TaskResult(true, null)
} finally {
cancelNotification(notification)
return coroutineScope {
withContext(serviceScope.coroutineContext) {
try {
TaskResult.completed(task.await())
} catch (e: CancellationException) {
TaskResult.cancelled()
} catch (e: Throwable) {
e.printStackTrace()
TaskResult.error(e.localizedMessage)
} finally {
cancelNotification(notification)
}
}
}
}
private fun copyFile(srcPath: String, dstPath: String, remoteEncryptedVolume: EncryptedVolume = encryptedVolume): Boolean {
private suspend fun <T> volumeTask(
volumeId: Int,
notification: FileOperationNotification,
task: suspend (encryptedVolume: EncryptedVolume) -> T
): TaskResult<out T> {
return waitForTask(
notification,
volumeManger.getCoroutineScope(volumeId).async {
task(getEncryptedVolume(volumeId))
}
)
}
private suspend fun copyFile(
encryptedVolume: EncryptedVolume,
srcPath: String,
dstPath: String,
srcEncryptedVolume: EncryptedVolume = encryptedVolume,
): Boolean {
var success = true
val srcFileHandle = remoteEncryptedVolume.openFileReadMode(srcPath)
val srcFileHandle = srcEncryptedVolume.openFileReadMode(srcPath)
if (srcFileHandle != -1L) {
val dstFileHandle = encryptedVolume.openFileWriteMode(dstPath)
if (dstFileHandle != -1L) {
var offset: Long = 0
val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
var length: Long
while (remoteEncryptedVolume.read(srcFileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it.toLong() } > 0) {
while (srcEncryptedVolume.read(srcFileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it.toLong() } > 0) {
yield()
val written = encryptedVolume.write(dstFileHandle, offset, ioBuffer, 0, length).toLong()
if (written == length) {
offset += written
@ -146,7 +184,7 @@ class FileOperationService : Service() {
} else {
success = false
}
remoteEncryptedVolume.closeFile(srcFileHandle)
srcEncryptedVolume.closeFile(srcFileHandle)
} else {
success = false
}
@ -154,25 +192,24 @@ class FileOperationService : Service() {
}
suspend fun copyElements(
items: ArrayList<OperationFile>,
remoteEncryptedVolume: EncryptedVolume = encryptedVolume
): String? = coroutineScope {
volumeId: Int,
items: List<OperationFile>,
srcVolumeId: Int = volumeId,
): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_copy_msg, items.size)
val task = async {
val srcEncryptedVolume = getEncryptedVolume(srcVolumeId)
return volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null
for (i in 0 until items.size) {
withContext(Dispatchers.IO) {
if (items[i].isDirectory) {
if (!encryptedVolume.pathExists(items[i].dstPath!!)) {
if (!encryptedVolume.mkdir(items[i].dstPath!!)) {
failedItem = items[i].srcPath
}
}
} else {
if (!copyFile(items[i].srcPath, items[i].dstPath!!, remoteEncryptedVolume)) {
for (i in items.indices) {
yield()
if (items[i].isDirectory) {
if (!encryptedVolume.pathExists(items[i].dstPath!!)) {
if (!encryptedVolume.mkdir(items[i].dstPath!!)) {
failedItem = items[i].srcPath
}
}
} else if (!copyFile(encryptedVolume, items[i].srcPath, items[i].dstPath!!, srcEncryptedVolume)) {
failedItem = items[i].srcPath
}
if (failedItem == null) {
updateNotificationProgress(notification, i+1, items.size)
@ -182,13 +219,11 @@ class FileOperationService : Service() {
}
failedItem
}
// treat cancellation as success
waitForTask(notification, task).failedItem
}
suspend fun moveElements(toMove: List<OperationFile>, toClean: List<String>): String? = coroutineScope {
suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_move_msg, toMove.size)
val task = async(Dispatchers.IO) {
return volumeTask(volumeId, notification) { encryptedVolume ->
val total = toMove.size+toClean.size
var failedItem: String? = null
for ((i, item) in toMove.withIndex()) {
@ -211,25 +246,23 @@ class FileOperationService : Service() {
}
failedItem
}
// treat cancellation as success
waitForTask(notification, task).failedItem
}
private suspend fun importFilesFromUris(
encryptedVolume: EncryptedVolume,
dstPaths: List<String>,
uris: List<Uri>,
notification: FileOperationNotification,
): String? {
var failedIndex = -1
for (i in dstPaths.indices) {
withContext(Dispatchers.IO) {
try {
if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
failedIndex = i
}
} catch (e: FileNotFoundException) {
yield()
try {
if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
failedIndex = i
}
} catch (e: FileNotFoundException) {
failedIndex = i
}
if (failedIndex == -1) {
updateNotificationProgress(notification, i+1, dstPaths.size)
@ -240,12 +273,11 @@ class FileOperationService : Service() {
return null
}
suspend fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>): TaskResult<String?> = coroutineScope {
suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_import_msg, dstPaths.size)
val task = async {
importFilesFromUris(dstPaths, uris, notification)
return volumeTask(volumeId, notification) { encryptedVolume ->
importFilesFromUris(encryptedVolume, dstPaths, uris, notification)
}
waitForTask(notification, task)
}
/**
@ -255,77 +287,63 @@ class FileOperationService : Service() {
*
* @return false if cancelled early, true otherwise.
*/
private fun recursiveMapDirectoryForImport(
private suspend fun recursiveMapDirectoryForImport(
rootSrcDir: DocumentFile,
rootDstPath: String,
dstFiles: ArrayList<String>,
srcUris: ArrayList<Uri>,
dstDirs: ArrayList<String>,
scope: CoroutineScope,
): Boolean {
) {
dstDirs.add(rootDstPath)
for (child in rootSrcDir.listFiles()) {
if (!scope.isActive) {
return false
}
yield()
child.name?.let { name ->
val subPath = PathUtils.pathJoin(rootDstPath, name)
if (child.isDirectory) {
if (!recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs, scope)) {
return false
}
}
else if (child.isFile) {
recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs)
} else if (child.isFile) {
srcUris.add(child.uri)
dstFiles.add(subPath)
}
}
}
return true
}
class ImportDirectoryResult(val taskResult: TaskResult<String?>, val uris: List<Uri>)
class ImportDirectoryResult(val taskResult: TaskResult<out String?>, val uris: List<Uri>)
suspend fun importDirectory(
volumeId: Int,
rootDstPath: String,
rootSrcDir: DocumentFile,
): ImportDirectoryResult = coroutineScope {
): ImportDirectoryResult {
val notification = showNotification(R.string.file_op_import_msg, null)
val srcUris = arrayListOf<Uri>()
val task = async {
return ImportDirectoryResult(volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null
val dstFiles = arrayListOf<String>()
val dstDirs = arrayListOf<String>()
withContext(Dispatchers.IO) {
if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, this)) {
return@withContext
}
// create destination folders so the new files can use them
for (dir in dstDirs) {
if (!encryptedVolume.mkdir(dir)) {
failedItem = dir
break
}
recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs)
// create destination folders so the new files can use them
for (dir in dstDirs) {
if (!encryptedVolume.mkdir(dir)) {
failedItem = dir
break
}
}
if (failedItem == null) {
failedItem = importFilesFromUris(dstFiles, srcUris, notification)
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, notification)
}
failedItem
}
ImportDirectoryResult(waitForTask(notification, task), srcUris)
}, srcUris)
}
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): String? = coroutineScope {
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_wiping_msg, uris.size)
val task = async {
val task = serviceScope.async(Dispatchers.IO) {
var errorMsg: String? = null
for (i in uris.indices) {
withContext(Dispatchers.IO) {
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
}
yield()
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
if (errorMsg == null) {
updateNotificationProgress(notification, i+1, uris.size)
} else {
@ -337,11 +355,10 @@ class FileOperationService : Service() {
}
errorMsg
}
// treat cancellation as success
waitForTask(notification, task).failedItem
return waitForTask(notification, task)
}
private fun exportFileInto(srcPath: String, treeDocumentFile: DocumentFile): Boolean {
private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
val outputStream = treeDocumentFile.createFile("*/*", File(srcPath).name)?.uri?.let {
contentResolver.openOutputStream(it)
}
@ -352,25 +369,20 @@ class FileOperationService : Service() {
}
}
private fun recursiveExportDirectory(
private suspend fun recursiveExportDirectory(
encryptedVolume: EncryptedVolume,
plain_directory_path: String,
treeDocumentFile: DocumentFile,
scope: CoroutineScope
): String? {
treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree ->
val explorerElements = encryptedVolume.readDir(plain_directory_path) ?: return null
for (e in explorerElements) {
if (!scope.isActive) {
return null
}
yield()
val fullPath = PathUtils.pathJoin(plain_directory_path, e.name)
if (e.isDirectory) {
val failedItem = recursiveExportDirectory(fullPath, childTree, scope)
failedItem?.let { return it }
} else {
if (!exportFileInto(fullPath, childTree)){
return fullPath
}
recursiveExportDirectory(encryptedVolume, fullPath, childTree)?.let { return it }
} else if (!exportFileInto(encryptedVolume, fullPath, childTree)) {
return fullPath
}
}
return null
@ -378,18 +390,20 @@ class FileOperationService : Service() {
return treeDocumentFile.name
}
suspend fun exportFiles(uri: Uri, items: List<ExplorerElement>): TaskResult<String?> = coroutineScope {
suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_export_msg, items.size)
val task = async {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
return volumeTask(volumeId, notification) { encryptedVolume ->
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
var failedItem: String? = null
for (i in items.indices) {
withContext(Dispatchers.IO) {
failedItem = if (items[i].isDirectory) {
recursiveExportDirectory(items[i].fullPath, treeDocumentFile, this)
yield()
failedItem = if (items[i].isDirectory) {
recursiveExportDirectory(encryptedVolume, items[i].fullPath, treeDocumentFile)
} else {
if (exportFileInto(encryptedVolume, items[i].fullPath, treeDocumentFile)) {
null
} else {
if (exportFileInto(items[i].fullPath, treeDocumentFile)) null else items[i].fullPath
items[i].fullPath
}
}
if (failedItem == null) {
@ -400,21 +414,37 @@ class FileOperationService : Service() {
}
failedItem
}
waitForTask(notification, task)
}
suspend fun removeElements(items: List<ExplorerElement>): String? = coroutineScope {
private suspend fun recursiveRemoveDirectory(encryptedVolume: EncryptedVolume, path: String): String? {
encryptedVolume.readDir(path)?.let { elements ->
for (e in elements) {
yield()
val fullPath = PathUtils.pathJoin(path, e.name)
if (e.isDirectory) {
recursiveRemoveDirectory(encryptedVolume, fullPath)?.let { return it }
} else if (!encryptedVolume.deleteFile(fullPath)) {
return fullPath
}
}
}
return if (!encryptedVolume.rmdir(path)) {
path
} else {
null
}
}
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
val notification = showNotification(R.string.file_op_delete_msg, items.size)
val task = async(Dispatchers.IO) {
return volumeTask(volumeId, notification) { encryptedVolume ->
var failedItem: String? = null
for ((i, element) in items.withIndex()) {
yield()
if (element.isDirectory) {
val result = encryptedVolume.recursiveRemoveDirectory(element.fullPath)
result?.let { failedItem = it }
} else {
if (!encryptedVolume.deleteFile(element.fullPath)) {
failedItem = element.fullPath
}
recursiveRemoveDirectory(encryptedVolume, element.fullPath)?.let { failedItem = it }
} else if (!encryptedVolume.deleteFile(element.fullPath)) {
failedItem = element.fullPath
}
if (failedItem == null) {
updateNotificationProgress(notification, i + 1, items.size)
@ -423,14 +453,11 @@ class FileOperationService : Service() {
}
}
failedItem
}
waitForTask(notification, task).failedItem
}.failedItem // treat cancellation as success
}
private fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int {
if (!scope.isActive) {
return 0
}
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int {
yield()
val children = rootDirectory.listFiles()
var count = children.size
for (child in children) {
@ -441,7 +468,7 @@ class FileOperationService : Service() {
return count
}
private fun recursiveCopyVolume(
private suspend fun recursiveCopyVolume(
src: DocumentFile,
dst: DocumentFile,
dstRootDirectory: ObjRef<DocumentFile?>?,
@ -453,9 +480,7 @@ class FileOperationService : Service() {
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
dstRootDirectory?.let { it.value = dstDir }
for (child in src.listFiles()) {
if (!scope.isActive) {
return null
}
yield()
if (child.isFile) {
val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child
val outputStream = contentResolver.openOutputStream(dstFile.uri)
@ -474,21 +499,16 @@ class FileOperationService : Service() {
return null
}
class CopyVolumeResult(val taskResult: TaskResult<DocumentFile?>, val dstRootDirectory: DocumentFile?)
class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult = coroutineScope {
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
val notification = showNotification(R.string.copy_volume_notification, null)
val dstRootDirectory = ObjRef<DocumentFile?>(null)
val task = async(Dispatchers.IO) {
val task = serviceScope.async(Dispatchers.IO) {
val total = recursiveCountChildElements(src, this)
if (isActive) {
updateNotificationProgress(notification, 0, total)
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this)
} else {
null
}
updateNotificationProgress(notification, 0, total)
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this)
}
// treat cancellation as success
CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
return CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
}
}

View File

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

View File

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

View File

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

View File

@ -1,16 +1,22 @@
package sushi.hardcore.droidfs.file_viewers
import android.view.WindowManager
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.source.MediaSource
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.video.VideoSize
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
import java.io.File
@OptIn(UnstableApi::class)
abstract class MediaPlayer: FileViewerActivity() {
private lateinit var player: ExoPlayer

View File

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

View File

@ -2,6 +2,7 @@ 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
import sushi.hardcore.droidfs.util.ObjRef
import sushi.hardcore.droidfs.util.PathUtils
@ -21,7 +22,8 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>?,
createBaseDir: Boolean,
cipher: String?
cipher: String?,
errorCode: ObjRef<Int?>,
): Long
private external fun nativeChangeEncryptionKey(
baseDir: String,
@ -58,22 +60,34 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
returnedHash: ObjRef<ByteArray?>?,
createBaseDir: Boolean,
cipher: String?
): CryfsVolume? {
val fusePtr = nativeInit(baseDir, localStateDir, password, givenHash, returnedHash, createBaseDir, cipher)
return if (fusePtr == 0L) {
null
): InitResult {
val errorCode = ObjRef<Int?>(null)
val fusePtr = nativeInit(baseDir, localStateDir, password, givenHash, returnedHash, createBaseDir, cipher, errorCode)
val result = InitResult.Builder()
if (fusePtr == 0L) {
result.errorCode = errorCode.value ?: 0
result.errorStringId = when (errorCode.value) {
// Values from src/cryfs/impl/ErrorCodes.h
11 -> {
result.worthRetry = true
R.string.wrong_password
}
16 -> R.string.inaccessible_base_dir
19 -> R.string.config_load_error
20 -> R.string.filesystem_id_changed
else -> 0
}
} else {
CryfsVolume(fusePtr)
result.volume = CryfsVolume(fusePtr)
}
return result.build()
}
fun create(baseDir: String, localStateDir: String, password: ByteArray, returnedHash: ObjRef<ByteArray?>?, cipher: String?, volume: ObjRef<EncryptedVolume?>): Boolean {
return init(baseDir, localStateDir, password, null, returnedHash, true, cipher)?.also {
volume.value = it
} != null
fun create(baseDir: String, localStateDir: String, password: ByteArray, returnedHash: ObjRef<ByteArray?>?, cipher: String?): EncryptedVolume? {
return init(baseDir, localStateDir, password, null, returnedHash, true, cipher).volume
}
fun init(baseDir: String, localStateDir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ObjRef<ByteArray?>?): CryfsVolume? {
fun init(baseDir: String, localStateDir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ObjRef<ByteArray?>?): InitResult {
return init(baseDir, localStateDir, password, givenHash, returnedHash, false, null)
}

View File

@ -8,13 +8,29 @@ 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.PathUtils
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
abstract class EncryptedVolume: Parcelable {
class InitResult(
val errorCode: Int,
val errorStringId: Int,
val worthRetry: Boolean,
val volume: EncryptedVolume?,
) {
class Builder {
var errorCode = 0
var errorStringId = 0
var worthRetry = false
var volume: EncryptedVolume? = null
fun build() = InitResult(errorCode, errorStringId, worthRetry, volume)
}
}
companion object {
const val GOCRYPTFS_VOLUME_TYPE: Byte = 0
const val CRYFS_VOLUME_TYPE: Byte = 1
@ -31,6 +47,11 @@ abstract class EncryptedVolume: Parcelable {
override fun newArray(size: Int) = arrayOfNulls<EncryptedVolume>(size)
}
/**
* Get the type of a volume.
*
* @return The volume type or -1 if the path is not recognized as a volume
*/
fun getVolumeType(path: String): Byte {
return if (File(path, GocryptfsVolume.CONFIG_FILE_NAME).isFile) {
GOCRYPTFS_VOLUME_TYPE
@ -47,7 +68,7 @@ abstract class EncryptedVolume: Parcelable {
password: ByteArray?,
givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>?
): EncryptedVolume? {
): InitResult {
return when (volume.type) {
GOCRYPTFS_VOLUME_TYPE -> {
GocryptfsVolume.init(
@ -139,7 +160,6 @@ abstract class EncryptedVolume: Parcelable {
if (written == length) {
offset += written
} else {
inputStream.close()
success = false
break
}
@ -202,25 +222,4 @@ abstract class EncryptedVolume: Parcelable {
}
return result
}
fun recursiveRemoveDirectory(path: String): String? {
readDir(path)?.let { elements ->
for (e in elements) {
val fullPath = PathUtils.pathJoin(path, e.name)
if (e.isDirectory) {
val result = recursiveRemoveDirectory(fullPath)
result?.let { return it }
} else {
if (!deleteFile(fullPath)) {
return fullPath
}
}
}
}
return if (!rmdir(path)) {
path
} else {
null
}
}
}

View File

@ -2,6 +2,7 @@ package sushi.hardcore.droidfs.filesystems
import android.os.Parcel
import android.util.Log
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.explorers.ExplorerElement
import sushi.hardcore.droidfs.util.ObjRef
import kotlin.math.min
@ -75,13 +76,23 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
}
}
fun init(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): GocryptfsVolume? {
fun init(root_cipher_dir: String, password: ByteArray?, givenHash: ByteArray?, returnedHash: ByteArray?): InitResult {
val sessionId = nativeInit(root_cipher_dir, password, givenHash, returnedHash)
return if (sessionId == -1) {
null
val result = InitResult.Builder()
if (sessionId < 0) {
result.errorCode = sessionId
result.errorStringId = when (sessionId) {
-1 -> R.string.config_load_error
-2 -> {
result.worthRetry = true
R.string.wrong_password
}
else -> 0
}
} else {
GocryptfsVolume(sessionId)
result.volume = GocryptfsVolume(sessionId)
}
return result.build()
}
init {

View File

@ -1,14 +1,17 @@
package sushi.hardcore.droidfs.filesystems
class Stat(val type: Int, var size: Long, val mTime: Long) {
class Stat(mode: Int, var size: Long, val mTime: Long) {
companion object {
private const val S_IFMT = 0xF000
const val S_IFDIR = 0x4000
const val S_IFREG = 0x8000
const val S_IFLNK = 0xA000
const val PARENT_FOLDER_TYPE = -1
const val PARENT_FOLDER_TYPE = 0xE000
fun parentFolderStat(): Stat {
return Stat(PARENT_FOLDER_TYPE, -1, -1)
}
}
val type = mode and S_IFMT
}

View File

@ -13,4 +13,11 @@ object Compat {
bundle.getParcelable(name)
}
}
val MEMFD_CREATE_MINIMUM_KERNEL_VERSION = Version("3.17")
fun isMemFileSupported(): Boolean {
val kernel = System.getProperty("os.version") ?: return false
return Version(kernel) >= MEMFD_CREATE_MINIMUM_KERNEL_VERSION
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import java.nio.ByteBuffer
class FFmpegMuxer(val writer: SeekableWriter): MediaMuxer {
external fun allocContext(): Long
external fun addVideoTrack(formatContext: Long, bitrate: Int, width: Int, height: Int, orientationHint: Int): Int
external fun addVideoTrack(formatContext: Long, bitrate: Int, frameRate: Int, width: Int, height: Int, orientationHint: Int): Int
external fun addAudioTrack(formatContext: Long, bitrate: Int, sampleRate: Int, channelCount: Int): Int
external fun writeHeaders(formatContext: Long): Int
external fun writePacket(formatContext: Long, buffer: ByteArray, pts: Long, streamIndex: Int, isKeyFrame: Boolean)
@ -54,6 +54,7 @@ class FFmpegMuxer(val writer: SeekableWriter): MediaMuxer {
addVideoTrack(
formatContext!!,
bitrate,
mediaFormat.getInteger("frame-rate"),
mediaFormat.getInteger("width"),
mediaFormat.getInteger("height"),
orientation
@ -82,7 +83,7 @@ class FFmpegMuxer(val writer: SeekableWriter): MediaMuxer {
}
fun writePacket(buff: ByteArray) {
writer.write(buff)
writer.write(buff, buff.size)
}
fun seek(offset: Long) {
writer.seek(offset)

View File

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

View File

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

View File

@ -17,14 +17,14 @@ 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 androidx.media3.ui.PlayerView
import sushi.hardcore.droidfs.R
class DoubleTapPlayerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : StyledPlayerView(context, attrs, defStyleAttr) {
) : PlayerView(context, attrs, defStyleAttr) {
companion object {
const val SEEK_SECONDS = 10

View File

@ -7,9 +7,9 @@ Java_sushi_hardcore_droidfs_filesystems_CryfsVolume_00024Companion_nativeInit(JN
jstring base_dir, jstring jlocalStateDir,
jbyteArray password, jbyteArray givenHash,
jobject returnedHash,
jboolean createBaseDir,
jstring cipher) {
return cryfs_init(env, base_dir, jlocalStateDir, password, givenHash, returnedHash, createBaseDir, cipher);
jboolean createBaseDir, jstring cipher,
jobject jerrorCode) {
return cryfs_init(env, base_dir, jlocalStateDir, password, givenHash, returnedHash, createBaseDir, cipher, jerrorCode);
}
JNIEXPORT jboolean JNICALL

View File

@ -3,9 +3,36 @@
#include <libavutil/channel_layout.h>
#include <libavutil/display.h>
#include <jni.h>
#include <android/log.h>
const char* LOG_TAG = "LIBMUX";
const size_t BUFF_SIZE = 4096;
int to_android_log_level(int level) {
switch (level) {
case AV_LOG_PANIC:
case AV_LOG_FATAL: return ANDROID_LOG_FATAL;
case AV_LOG_ERROR: return ANDROID_LOG_ERROR;
case AV_LOG_WARNING: return ANDROID_LOG_WARN;
case AV_LOG_INFO: return ANDROID_LOG_INFO;
default: return ANDROID_LOG_UNKNOWN;
}
}
void log_callback(void *ptr, int level, const char *fmt, va_list vl)
{
char line[1024];
static int print_prefix = 1;
av_log_format_line(ptr, level, fmt, vl, line, sizeof(line), &print_prefix);
__android_log_print(to_android_log_level(level), LOG_TAG, "%s", line);
}
void log_err(int result, const char* name) {
if (result < 0) {
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "%s: %d", name, result);
}
}
struct Muxer {
JavaVM* jvm;
jobject thiz;
@ -32,6 +59,8 @@ int64_t seek(void* opaque, int64_t offset, int whence) {
}
jlong Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_allocContext(JNIEnv *env, jobject thiz) {
av_log_set_callback(log_callback);
av_log_set_level(AV_LOG_INFO);
const AVOutputFormat *format = av_guess_format("mp4", NULL, NULL);
struct Muxer* muxer = malloc(sizeof(struct Muxer));
(*env)->GetJavaVM(env, &muxer->jvm);
@ -50,17 +79,14 @@ JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_addAudioTrack(JNIEnv *env, jobject thiz, jlong format_context, jint bitrate, jint sample_rate,
jint channel_count) {
const AVCodec* encoder = avcodec_find_encoder(AV_CODEC_ID_AAC);
AVStream* stream = avformat_new_stream((AVFormatContext *) format_context, NULL);
AVCodecContext* codec_context = avcodec_alloc_context3(encoder);
av_channel_layout_default(&codec_context->ch_layout, channel_count);
codec_context->sample_rate = sample_rate;
codec_context->sample_fmt = encoder->sample_fmts[0];
codec_context->bit_rate = bitrate;
codec_context->strict_std_compliance = FF_COMPLIANCE_EXPERIMENTAL;
stream->time_base.den = sample_rate;
stream->time_base.num = 1;
codec_context->flags = AV_CODEC_FLAG_GLOBAL_HEADER;
codec_context->time_base = (AVRational) {1, sample_rate};
avcodec_open2(codec_context, encoder, NULL);
AVStream* stream = avformat_new_stream((AVFormatContext *) format_context, NULL);
avcodec_parameters_from_context(stream->codecpar, codec_context);
avcodec_free_context(&codec_context);
return stream->index;
@ -69,7 +95,9 @@ Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_addAudioTrack(JNIEnv *e
JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_addVideoTrack(JNIEnv *env, jobject thiz,
jlong format_context,
jint bitrate, jint width,
jint bitrate,
jint frame_rate,
jint width,
jint height,
jint orientation_hint) {
AVStream* stream = avformat_new_stream((AVFormatContext *) format_context, NULL);
@ -78,6 +106,8 @@ Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_addVideoTrack(JNIEnv *e
stream->codecpar->bit_rate = bitrate;
stream->codecpar->width = width;
stream->codecpar->height = height;
stream->codecpar->format = AV_PIX_FMT_YUVJ420P;
stream->time_base = (AVRational) {1, frame_rate};
uint8_t* matrix = av_stream_new_side_data(stream, AV_PKT_DATA_DISPLAYMATRIX, sizeof(int32_t) * 9);
av_display_rotation_set((int32_t *) matrix, orientation_hint);
return stream->index;
@ -85,7 +115,10 @@ Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_addVideoTrack(JNIEnv *e
JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_writeHeaders(JNIEnv *env, jobject thiz, jlong format_context) {
return avformat_write_header((AVFormatContext *) format_context, NULL);
av_dump_format((AVFormatContext *) format_context, 0, NULL, 1);
int result = avformat_write_header((AVFormatContext *) format_context, NULL);
log_err(result, "avformat_write_header");
return result;
}
JNIEXPORT void JNICALL
@ -100,26 +133,28 @@ Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_writePacket(JNIEnv *env
r.num = 1;
r.den = 1000000;
packet->pts = av_rescale_q(pts, r, ((AVFormatContext*)format_context)->streams[stream_index]->time_base);
packet->dts = packet->pts;
uint8_t* buff = malloc(size);
(*env)->GetByteArrayRegion(env, buffer, 0, size, (signed char*)buff);
packet->data = buff;
if (is_key_frame) {
packet->flags = AV_PKT_FLAG_KEY;
}
av_write_frame((AVFormatContext *)format_context, packet);
log_err(av_write_frame((AVFormatContext *)format_context, packet), "av_write_frame");
free(buff);
av_packet_free(&packet);
}
JNIEXPORT void JNICALL
Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_writeTrailer(JNIEnv *env, jobject thiz, jlong format_context) {
av_write_trailer((AVFormatContext *) format_context);
log_err(av_write_trailer((AVFormatContext *) format_context), "av_write_trailer");
}
JNIEXPORT void JNICALL
Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_release(JNIEnv *env, jobject thiz, jlong format_context) {
AVFormatContext* fc = (AVFormatContext *) format_context;
av_free(fc->pb->buffer);
(*env)->DeleteGlobalRef(env, ((struct Muxer*)fc->pb->opaque)->thiz);
free(fc->pb->opaque);
avio_context_free(&fc->pb);
avformat_free_context(fc);

View File

@ -0,0 +1,30 @@
#include <errno.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <jni.h>
#include <android/log.h>
const char* LOG_TAG = "MemFile";
void log_err(const char* function) {
__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, "%s(): %s", function, strerror(errno));
}
JNIEXPORT jint JNICALL
Java_sushi_hardcore_droidfs_MemFile_00024Companion_createMemFile(JNIEnv *env, jobject thiz, jstring jname,
jlong size) {
const char* name = (*env)->GetStringUTFChars(env, jname, NULL);
int fd = syscall(SYS_memfd_create, name, MFD_CLOEXEC);
if (fd < 0) {
log_err("memfd_create");
return fd;
}
if (ftruncate64(fd, size) == -1) {
log_err("ftruncate64");
close(fd);
return -1;
}
return fd;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

View File

@ -0,0 +1,5 @@
<vector android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="?attr/colorAccent" android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"/>
</vector>

View File

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

View File

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

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textIsSelectable="true"
android:typeface="monospace" />
</HorizontalScrollView>
</ScrollView>

View File

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

View File

@ -8,19 +8,19 @@
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">
android:clipToPadding="false"
android:layoutDirection="ltr">
<ImageButton android:id="@id/exo_prev"
style="@style/ExoStyledControls.Button.Center.Previous"/>
<include layout="@layout/exo_styled_player_control_rewind_button" />
<include layout="@layout/exo_player_control_rewind_button" />
<ImageButton android:id="@id/exo_play_pause"
style="@style/ExoStyledControls.Button.Center.PlayPause"/>
<include layout="@layout/exo_styled_player_control_ffwd_button" />
<include layout="@layout/exo_player_control_ffwd_button" />
<ImageButton android:id="@id/exo_next"
style="@style/ExoStyledControls.Button.Center.Next"/>

View File

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

View File

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

View File

@ -27,4 +27,34 @@
<item>حجم (وصف)</item>
<item>تاريخ (وصف)</item>
</string-array>
</resources>
<string-array name="color_names">
<item>أخضر</item>
<item>أحمر</item>
<item>أزرق</item>
<item>أصفر</item>
<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>
</resources>

View File

@ -27,7 +27,6 @@
<string name="dir_not_empty">إن المجلد المحدد ليس فارغاً</string>
<string name="create_volume_failed">إن عملية إنشاء المجلد المشفر قد فشلت.</string>
<string name="open_volume_failed">عملية الفتح قد فشلت</string>
<string name="open_volume_failed_msg">لقد تعذر فتح المجلد المشفر. من فضلك تحقق من كلمة المرور.</string>
<string name="share_chooser">مشاركة ملف</string>
<string name="storage_perm_denied">إن صلاحية التخزين مرفوضة</string>
<string name="storage_perm_denied_msg">إن DroidFS لا يمكنه العمل بدون صلاحبات التخزين.</string>
@ -58,7 +57,6 @@
<string name="listdir_null_error_msg">تعذر الوصول إلى هذا المسار</string>
<string name="fingerprint_save_checkbox_text">حفظ كلمة المرور ببصمة الإصبع</string>
<string name="fingerprint_instruction">يرجى لمس مستشعر بصمة الإصبع</string>
<string name="open_failed_hash_msg">فشل في فتح المجلد المشفر. ربما تغيرت كلمة المرور.</string>
<string name="illegal_block_size_exception">IllegalBlockSizeException</string>
<string name="illegal_block_size_exception_msg">يمكن أن يحدث هذا إذا أضفت بصمة إصبع جديدة. يمكنك أن تقوم بإعادة تعيين تخزين التجزئة لحل هذه المشكلة.</string>
<string name="reset_hash_storage">إعادة تعيين تخزين التجزئة</string>
@ -245,4 +243,29 @@
<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">"إطار الوصول للقرص"</string>
<string name="tmp_export_failed">لقد فشل تصدير: %s</string>
<string name="export_failed_create">تعذر إنشاء الملف المستخرج</string>
<string name="export_failed_export">فشل إستخراج الملف</string>
<string name="export_mem">يتم الإستخراج إلى الذاكرة المؤقتة…</string>
<string name="export_disk">يتم الإستخراج إلى القرص…</string>
</resources>

View File

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

View File

@ -0,0 +1,276 @@
<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="enter_volume_path">Bitte geben Sie den Volume-Pfad ein</string>
<string name="enter_volume_name">Bitte geben Sie den Datenträgernamen ein</string>
<string name="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="usf_keep_open">Volumen offen halten, wenn die App in den Hintergrund geht</string>
<string name="unsafe_features">Unsichere Funktionen</string>
<string name="manage_unsafe_features">Sichere Funktionen verwalten</string>
<string name="manage_unsafe_features_summary">Aktivieren/Deaktivieren unsicherer Funktionen</string>
<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>

View File

@ -28,7 +28,6 @@
<string name="dir_not_empty">El directorio seleccionado no está vacío</string>
<string name="create_volume_failed">La creación del volumen ha fallado.</string>
<string name="open_volume_failed">Fallo al abrir</string>
<string name="open_volume_failed_msg">No se ha podido abrir el volumen. Por favor, comprueba tu contraseña.</string>
<string name="share_chooser">Compartir archivo</string>
<string name="storage_perm_denied">Permiso de almacenamiento denegado</string>
<string name="storage_perm_denied_msg">DroidFS no puede funcionar sin permisos de almacenamiento.</string>
@ -59,7 +58,6 @@
<string name="listdir_null_error_msg">No se puede acceder a este directorio</string>
<string name="fingerprint_save_checkbox_text">Guardar el hash de la contraseña mediante la huella dactilar</string>
<string name="fingerprint_instruction">Por favor, toca el sensor de huellas dactilares</string>
<string name="open_failed_hash_msg">No se ha podido abrir el volumen. La contraseña puede haber cambiado.</string>
<string name="illegal_block_size_exception">IllegalBlockSizeException</string>
<string name="illegal_block_size_exception_msg">Esto puede ocurrir si has añadido una nueva huella digital. Restablecer el almacenamiento de hash puede resolver este problema.</string>
<string name="reset_hash_storage">Restablecer el almacenamiento de hash</string>
@ -244,6 +242,7 @@
<string name="file_op_delete_msg">Eliminando archivos…</string>
<string name="volume_type">(%s)</string>
<string name="volume_type_read_only">(%s, Sólo lectura)</string>
<string name="volume_type_inaccessible">(%s, inaccesible)</string>
<string name="io_error">I/O Error.</string>
<string name="use_fingerprint">Utilizar huella dactilar en lugar de la contraseña actual</string>
<string name="remember_volume">Recordar volumen</string>
@ -257,5 +256,21 @@
<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 la contraseña cuando se cancela la autenticación por huella dactilar</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>
</resources>

View File

@ -27,7 +27,6 @@
<string name="dir_not_empty">A pasta selecionada não está vazia</string>
<string name="create_volume_failed">Falha ao criar o volume.</string>
<string name="open_volume_failed">Não foi possível abrir</string>
<string name="open_volume_failed_msg">Falha ao abrir o volume. Por favor, verifique sua senha.</string>
<string name="share_chooser">Compartilhar arquivo</string>
<string name="storage_perm_denied">Permissão do armazenamento negada</string>
<string name="storage_perm_denied_msg">DroidFS não pode funcionar sem permissão ao armazenamento.</string>
@ -58,7 +57,6 @@
<string name="listdir_null_error_msg">Não foi possível acessar a pasta</string>
<string name="fingerprint_save_checkbox_text">Salvar o hash da senha usando impressão digital</string>
<string name="fingerprint_instruction">Por favor, toque no sensor da impressão digital</string>
<string name="open_failed_hash_msg">Falha ao abrir o volume. A senha podia ter mudado.</string>
<string name="illegal_block_size_exception">IllegalBlockSizeException</string>
<string name="illegal_block_size_exception_msg">Isto poderia ter acontecido, se você tiver adicionado uma nova impressão digital. Tente resetar o hash do armazenamento para resolver este problema.</string>
<string name="reset_hash_storage">Resetar o hash do armazenamento</string>

View File

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

View File

@ -26,7 +26,6 @@
<string name="dir_not_empty">Выбранная папка не пустая</string>
<string name="create_volume_failed">Невозможно создать том.</string>
<string name="open_volume_failed">Ошибка открытия</string>
<string name="open_volume_failed_msg">Невозможно открыть том. Проверьте пароль.</string>
<string name="share_chooser">Поделиться файлом</string>
<string name="storage_perm_denied">Доступ к хранилищу запрещён</string>
<string name="storage_perm_denied_msg">DroidFS не может работать без разрешения на доступ к хранилищу.</string>
@ -57,7 +56,6 @@
<string name="listdir_null_error_msg">Невозможно получить доступ к этой папке.</string>
<string name="fingerprint_save_checkbox_text">Сохранить хеш пароля отпечатком пальца</string>
<string name="fingerprint_instruction">Прикоснитесь к сканеру отпечатков пальцев</string>
<string name="open_failed_hash_msg">Невозможно открыть том. Возможно, изменился пароль.</string>
<string name="illegal_block_size_exception_msg">Это могло произойти, если вы добавили новый отпечаток пальца. Сброс хранилища хешей должен решить эту проблему.</string>
<string name="reset_hash_storage">Сброс хранилища хешей</string>
<string name="MAC_verification_failed">Проверка подписи/MAC не выполнена. Хранилище ключей Android или сохранённый хеш был изменён. Сброс хранилища хешей должен решить эту проблему.</string>
@ -233,7 +231,8 @@
<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_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>
@ -248,4 +247,25 @@
<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>
</resources>

View File

@ -0,0 +1,72 @@
<resources>
<string-array name="gocryptfs_encryption_ciphers">
<item>AES-GCM</item>
<item>XChaCha20-Poly1305</item>
<item>@string/auto</item>
</string-array>
<string-array name="cryfs_encryption_ciphers">
<item>xchacha20-poly1305</item>
<item>aes-256-gcm</item>
<item>aes-128-gcm</item>
<item>twofish-256-gcm</item>
<item>twofish-128-gcm</item>
<item>serpent-256-gcm</item>
<item>serpent-128-gcm</item>
<item>cast-256-gcm</item>
<item>mars-448-gcm</item>
<item>mars-256-gcm</item>
<item>mars-128-gcm</item>
</string-array>
<string-array name="sort_orders_entries">
<item>Ad</item>
<item>Boyut</item>
<item>Tarih</item>
<item>Ad (azalan)</item>
<item>Boyut (azalan)</item>
<item>Tarih (azalan)</item>
</string-array>
<string-array name="color_names">
<item>Yeşil</item>
<item>Kırmızı</item>
<item>Mavi</item>
<item>Sarı</item>
<item>Turuncu</item>
<item>Mor</item>
<item>Pembe</item>
</string-array>
<string-array name="export_methods">
<item>Otomatik (kullanılabilir belleğe bağlı olarak)</item>
<item>Diske geçici dışa aktarma (güvenilir ancak iz bırakabilir)</item>
<item>Bellek dosyası (daha güvenlidir ancak her zaman işe yaramaz)</item>
</string-array>
<!-- don't translate the following otherwise the app will crash -->
<string-array name="sort_orders_values">
<item>name</item>
<item>size</item>
<item>date</item>
<item>name_desc</item>
<item>size_desc</item>
<item>date_desc</item>
</string-array>
<string-array name="color_values">
<item>green</item>
<item>red</item>
<item>blue</item>
<item>yellow</item>
<item>orange</item>
<item>purple</item>
<item>pink</item>
</string-array>
<string-array name="export_methods_values">
<item>auto</item>
<item>disk</item>
<item>memory</item>
</string-array>
</resources>

View File

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

View File

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

View File

@ -28,7 +28,6 @@
<string name="dir_not_empty">The selected directory isn\'t empty</string>
<string name="create_volume_failed">The volume creation has failed.</string>
<string name="open_volume_failed">Open failed</string>
<string name="open_volume_failed_msg">Failed to open the volume. Please check your password.</string>
<string name="share_chooser">Share File</string>
<string name="storage_perm_denied">Storage permission denied</string>
<string name="storage_perm_denied_msg">DroidFS can\'t work without storage permissions.</string>
@ -59,7 +58,6 @@
<string name="listdir_null_error_msg">Unable to access this directory</string>
<string name="fingerprint_save_checkbox_text">Save password hash using fingerprint</string>
<string name="fingerprint_instruction">Please touch the fingerprint sensor</string>
<string name="open_failed_hash_msg">Failed to open the volume. The password may have changed.</string>
<string name="illegal_block_size_exception">IllegalBlockSizeException</string>
<string name="illegal_block_size_exception_msg">This can happen if you have added a new fingerprint. Resetting hash storage can solve this problem.</string>
<string name="reset_hash_storage">Reset hash storage</string>
@ -244,6 +242,7 @@
<string name="file_op_delete_msg">Deleting files…</string>
<string name="volume_type">(%s)</string>
<string name="volume_type_read_only">(%s, read-only)</string>
<string name="volume_type_inaccessible">(%s, inaccessible)</string>
<string name="io_error">I/O Error.</string>
<string name="use_fingerprint">Use fingerprint instead of current password</string>
<string name="remember_volume">Remember volume</string>
@ -258,4 +257,26 @@
<string name="black_theme">Black theme</string>
<string name="password_fallback">Fallback to password</string>
<string name="password_fallback_summary">Prompt for password when fingerprint authentication is cancelled</string>
<string name="unknown_error_code">Unknown error code: %d</string>
<string name="config_load_error">The configuration file cannot be loaded. Make sure the volume is accessible.</string>
<string name="wrong_password">The configuration file cannot be decrypted. Please check your password.</string>
<string name="filesystem_id_changed">The filesystem id in the config file is different to the last time we opened this volume. This could mean an attacker replaced the filesystem with a different one.</string>
<string name="inaccessible_base_dir">The volume doesn\'t exist or is inaccessible.</string>
<string name="task_failed">The task failed: %s</string>
<string name="usf_expose">Expose open volumes</string>
<string name="usf_expose_summary">Allow other applications to browse open volumes as documents providers</string>
<string name="usf_saf_write">Grant write access</string>
<string name="usf_saf_write_summary">Grant write access when opening files with other applications</string>
<string name="saf">Storage Access Framework</string>
<string name="tmp_export_failed">Export failed: %s</string>
<string name="export_failed_create">can\'t create exported file</string>
<string name="export_failed_export">failed to export file</string>
<string name="export_mem">Exporting to memory…</string>
<string name="export_disk">Exporting to disk…</string>
<string name="memfd_create_unsupported">Your current kernel does not support memfd_create(). This feature requires a minimum kernel version of %s.</string>
<string name="export_method">Export method</string>
<string name="export_method_summary">File export method. Used for sharing, external opening and accessing exposed files.</string>
<string name="debug">Debug</string>
<string name="logcat_title">DroidFS Logcat</string>
<string name="logcat_saved">Logcat saved</string>
</resources>

View File

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

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