forked from hardcoresushi/DroidFS
Compare commits
61 Commits
volume-pro
...
master
Author | SHA1 | Date | |
---|---|---|---|
82dda95211 | |||
40bed2db21 | |||
f901495e41 | |||
07f5f8b5d9 | |||
2d5f5a82c9 | |||
b477272d65 | |||
88bd746359 | |||
9872cab7c2 | |||
4aa211bca4 | |||
0a1406769b | |||
a62f32e364 | |||
f865c864a2 | |||
e804059b23 | |||
|
bb821d5f41 | ||
6c0e20c68e | |||
e9b67bd9c4 | |||
c0dcaed8d2 | |||
85e24921fa | |||
15f288be11 | |||
bb49501403 | |||
33d565bf22 | |||
52a29b034c | |||
d44601f69f | |||
4b002c7b24 | |||
7c72c4e829 | |||
bd60e62635 | |||
|
d1e042c347 | ||
|
0805ebda35 | ||
|
36e6ad99b3 | ||
|
967d4551c5 | ||
|
b747d2822a | ||
|
e5652666d8 | ||
|
cda0e90b96 | ||
6f43bc7417 | |||
c26ab661c2 | |||
1c15f9fac8 | |||
b4635dc2e0 | |||
f4e47c1827 | |||
5474d6eea5 | |||
719faa31ee | |||
a41cde1c53 | |||
b503f134d5 | |||
3ba774fda3 | |||
b2154d319e | |||
571a79cc1d | |||
891a581329 | |||
f1a9c1383c | |||
ac71ad887d | |||
e1fe329f49 | |||
dfff597ae5 | |||
bd429648b3 | |||
71ff37b170 | |||
4afe56b13c | |||
217334a959 | |||
|
2666313676 | ||
|
04e154a6d9 | ||
|
d3760e2194 | ||
d6c777875e | |||
8a18270b33 | |||
79db84f81d | |||
6d04349b2e |
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -7,3 +7,6 @@
|
|||||||
[submodule "app/libcryfs"]
|
[submodule "app/libcryfs"]
|
||||||
path = app/libcryfs
|
path = app/libcryfs
|
||||||
url = https://forge.chapril.org/hardcoresushi/libcryfs.git
|
url = https://forge.chapril.org/hardcoresushi/libcryfs.git
|
||||||
|
[submodule "app/ffmpeg/ffmpeg"]
|
||||||
|
path = app/ffmpeg/ffmpeg
|
||||||
|
url = https://git.ffmpeg.org/ffmpeg.git
|
||||||
|
53
BUILD.md
53
BUILD.md
@ -1,18 +1,21 @@
|
|||||||
# Introduction
|
# Introduction
|
||||||
DroidFS relies on modified versions of the original encrypted filesystems programs to open volumes. [CryFS](https://github.com/cryfs/cryfs) is written in C++ while [gocryptfs](https://github.com/rfjakob/gocryptfs) is written in [Go](https://golang.org). Thus, building DroidFS requires the compilation of native code. However, for the sake of simplicity, the application has been designed in a modular way: you can build a version of DroidFS that supports both Gocryptfs and CryFS, or only one of the two.
|
DroidFS relies on modified versions of the original encrypted filesystems programs to open volumes. [CryFS](https://github.com/cryfs/cryfs) is written in C++ while [gocryptfs](https://github.com/rfjakob/gocryptfs) is written in [Go](https://golang.org). Thus, building DroidFS requires the compilation of native code. However, for the sake of simplicity, the application has been designed in a modular way: you can build a version of DroidFS that supports both Gocryptfs and CryFS, or only one of the two.
|
||||||
|
|
||||||
Moreover, DroidFS aims to be accessible to as many people as possible. If you encounter any problems or need help with the build, feel free to open an issue, a discussion, or contact me by [email](mailto:hardcore.sushi@disroot.org) or on [Matrix](https://matrix.org): @hardcoresushi:matrix.underworld.fr
|
Moreover, DroidFS aims to be accessible to as many people as possible. If you encounter any problems or need help with the build, feel free to open an issue, a discussion, or contact me (currently the main developer) by [email](mailto:gh@arkensys.dedyn.io) or on [Matrix](https://matrix.org): @hardcoresushi:matrix.underworld.fr
|
||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
|
|
||||||
|
The following two steps assume you're using a Debian-based Linux distribution. Package names might be similar for other distributions. Don't hesitate to ask if you're having trouble with this.
|
||||||
|
|
||||||
Install required packages:
|
Install required packages:
|
||||||
```
|
```
|
||||||
$ sudo apt-get install openjdk-11-jdk-headless build-essential pkg-config git gnupg2 wget apksigner
|
$ sudo apt-get install openjdk-17-jdk-headless build-essential pkg-config git gnupg2 wget apksigner npm
|
||||||
```
|
```
|
||||||
You also need to manually install the [Android SDK](https://developer.android.com/studio/index.html#command-tools) and the [Android Native Development Kit (NDK)](https://developer.android.com/ndk/downloads) (r23 versions are recommended).
|
You also need to manually install the [Android SDK](https://developer.android.com/studio/index.html#command-tools) and the [Android Native Development Kit (NDK)](https://github.com/android/ndk/wiki/Unsupported-Downloads#r25c) version `25.2.9519653` (r25c). libcryfs cannot be built with newer NDK versions at the moment due to compatibility issues with [boost](https://www.boost.org). If you succeed in building it with a more recent version of NDK, please report it.
|
||||||
|
|
||||||
If you want a support for Gocryptfs volumes, you must install [Go](https://golang.org/doc/install) and libssl:
|
If you want a support for Gocryptfs volumes, you need to install [Go](https://golang.org/doc/install):
|
||||||
```
|
```
|
||||||
$ sudo apt-get install golang-go libssl-dev
|
$ sudo apt-get install golang-go
|
||||||
```
|
```
|
||||||
The code should be authenticated before being built. To verify the signatures, you will need my PGP key:
|
The code should be authenticated before being built. To verify the signatures, you will need my PGP key:
|
||||||
```
|
```
|
||||||
@ -35,31 +38,17 @@ __Don't continue if the verification fails!__
|
|||||||
|
|
||||||
Initialize submodules:
|
Initialize submodules:
|
||||||
```
|
```
|
||||||
$ git submodule update --depth=1 --init
|
$ git submodule update --init
|
||||||
```
|
```
|
||||||
[FFmpeg](https://ffmpeg.org) is needed to record encrypted video:
|
If you want Gocryptfs support, initliaze libgocryptfs submodules:
|
||||||
```
|
```
|
||||||
$ cd app/ffmpeg
|
$ cd app/libgocryptfs
|
||||||
$ git clone --depth=1 https://git.ffmpeg.org/ffmpeg.git
|
$ git submodule update --init
|
||||||
```
|
```
|
||||||
If you want Gocryptfs support, you need to download OpenSSL:
|
If you want CryFS support, initialize libcryfs submodules:
|
||||||
```
|
|
||||||
$ cd ../libgocryptfs
|
|
||||||
$ wget https://www.openssl.org/source/openssl-1.1.1t.tar.gz
|
|
||||||
```
|
|
||||||
Verify OpenSSL signature:
|
|
||||||
```
|
|
||||||
$ wget https://www.openssl.org/source/openssl-1.1.1t.tar.gz.asc
|
|
||||||
$ gpg --verify openssl-1.1.1t.tar.gz.asc openssl-1.1.1t.tar.gz
|
|
||||||
```
|
|
||||||
Continue **ONLY** if the signature is **VALID**.
|
|
||||||
```
|
|
||||||
$ tar -xzf openssl-1.1.1t.tar.gz
|
|
||||||
```
|
|
||||||
If you want CryFS support, initialize libcryfs:
|
|
||||||
```
|
```
|
||||||
$ cd app/libcryfs
|
$ cd app/libcryfs
|
||||||
$ git submodule update --depth=1 --init
|
$ git submodule update --init
|
||||||
```
|
```
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
@ -67,31 +56,33 @@ Retrieve your Android NDK installation path, usually something like `/home/\<use
|
|||||||
```
|
```
|
||||||
$ export ANDROID_NDK_HOME="<your ndk path>"
|
$ export ANDROID_NDK_HOME="<your ndk path>"
|
||||||
```
|
```
|
||||||
|
If you know your CPU ABI, you can specify it to build scripts in order to speed up compilation time. If you don't know it, or want to build for all ABIs, just leave the field blank.
|
||||||
|
|
||||||
Start by compiling FFmpeg:
|
Start by compiling FFmpeg:
|
||||||
```
|
```
|
||||||
$ cd app/ffmpeg
|
$ cd app/ffmpeg
|
||||||
$ ./build.sh ffmpeg
|
$ ./build.sh [<ABI>]
|
||||||
```
|
```
|
||||||
## libgocryptfs
|
## libgocryptfs
|
||||||
This step is only required if you want Gocryptfs support.
|
This step is only required if you want Gocryptfs support.
|
||||||
```
|
```
|
||||||
$ cd app/libgocryptfs
|
$ cd app/libgocryptfs
|
||||||
$ OPENSSL_PATH="./openssl-1.1.1t" ./build.sh
|
$ ./build.sh [<ABI>]
|
||||||
```
|
```
|
||||||
## Compile APKs
|
## Compile APKs
|
||||||
Gradle build libgocryptfs and libcryfs by default.
|
Gradle build libgocryptfs and libcryfs by default.
|
||||||
|
|
||||||
To build DroidFS without Gocryptfs support, run:
|
To build DroidFS without Gocryptfs support, run:
|
||||||
```
|
```
|
||||||
$ ./gradlew assembleRelease -PdisableGocryptfs=true
|
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableGocryptfs=true
|
||||||
```
|
```
|
||||||
To build DroidFS without CryFS support, run:
|
To build DroidFS without CryFS support, run:
|
||||||
```
|
```
|
||||||
$ ./gradlew assembleRelease -PdisableCryFS=true
|
$ ./gradlew assembleRelease [-Pabi=<ABI>] -PdisableCryFS=true
|
||||||
```
|
```
|
||||||
If you want to build DroidFS with support for both Gocryptfs and CryFS, just run:
|
If you want to build DroidFS with support for both Gocryptfs and CryFS, just run:
|
||||||
```
|
```
|
||||||
$ ./gradlew assembleRelease
|
$ ./gradlew assembleRelease [-Pabi=<ABI>]
|
||||||
```
|
```
|
||||||
|
|
||||||
# Sign APKs
|
# Sign APKs
|
||||||
|
76
README.md
76
README.md
@ -11,7 +11,7 @@ For mortals: Encrypted storage compatible with already existing softwares.
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
# Support
|
# Support
|
||||||
The creator of DroidFS works as a freelance developer and privacy consultant. I am currently looking for new clients! If you are interested, take a look at the [website](https://arkensys.fr.to). Alternatively, you can directly support DroidFS by making a [donation](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/DONATE.txt).
|
The creator of DroidFS works as a freelance developer and privacy consultant. I am currently looking for new clients! If you are interested, take a look at the [website](https://arkensys.dedyn.io). Alternatively, you can directly support DroidFS by making a [donation](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/DONATE.txt).
|
||||||
|
|
||||||
Thank you so much ❤️.
|
Thank you so much ❤️.
|
||||||
|
|
||||||
@ -28,34 +28,47 @@ Do not use this app with volumes containing sensitive data unless you know exact
|
|||||||
- Unlocking volumes using fingerprint authentication
|
- Unlocking volumes using fingerprint authentication
|
||||||
- Volume auto-locking when the app goes in background
|
- Volume auto-locking when the app goes in background
|
||||||
|
|
||||||
_For upcoming features, see [TODO.md](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/TODO.md)._
|
For planned features, see [TODO.md](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/TODO.md).
|
||||||
|
|
||||||
# Unsafe features
|
# 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.
|
Some available features are considered risky and are therefore disabled by default. It is strongly recommended that you read the following documentation if you wish to activate one of these options.
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li><h4>Allow screenshots:</h4>
|
<li><b>Allow screenshots:</b>
|
||||||
|
|
||||||
Disable the secure flag of DroidFS activities. This will allow you to take screenshots from the app, but will also allow other apps to record the screen while using DroidFS.
|
Disable the secure flag of DroidFS activities. This will allow you to take screenshots from the app, but will also allow other apps to record the screen while using DroidFS.
|
||||||
|
|
||||||
Note: apps with root access don't care about this flag: they can take screenshots or record the screen of any app without any permissions.
|
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>
|
<li><b>Allow exporting files:</b>
|
||||||
<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.
|
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.</li>
|
||||||
</li>
|
<li><b>Allow sharing files via the android share menu⁽¹⁾:</b>
|
||||||
<li><h4>Allow exporting files:</h4>
|
|
||||||
Decrypt and write file to disk (external storage). Any app with storage permissions could access exported files.
|
Decrypt and share file with other apps. These apps could save and send the files thus shared.</li>
|
||||||
</li>
|
<li><b>Allow saving password hash using fingerprint:</b>
|
||||||
<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.
|
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>
|
<li><b>Disable volume auto-locking:</b> (previously called <i>"Keep volumes open when the app goes in background"</i>)
|
||||||
<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.
|
Don't close open volumes when you leave the app. Anyone going back to the application could have access to open volumes. Cryptographic secrets are kept in memory for an undefined amount of time.</li>
|
||||||
</li>
|
<li><b>Keep volumes open:</b>
|
||||||
<li><h4>Allow saving password hash using fingerprint:</h4>
|
(Different from the old <i>"Keep volumes open when the app goes in background"</i>. Yes it's confusing, sorry)
|
||||||
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>
|
Keep the app running as a [foreground service](https://developer.android.com/develop/background-work/services/foreground-services) to maintain volumes open, even when the app is removed from recent tasks.
|
||||||
|
|
||||||
|
This avoid the app from being killed by the system during file operations or while accessing exposed volumes, but this mean cryptographic secrets stay in memory for an undefined amount of time.</li>
|
||||||
|
<li><b>Allow opening files with other applications⁽¹⁾:</b>
|
||||||
|
|
||||||
|
Decrypt and open file using external apps. These apps could save and send the files thus opened.</li>
|
||||||
|
<li><b>Expose open volumes⁽¹⁾:</b>
|
||||||
|
|
||||||
|
Allow open volumes to be browsed in the system file explorer (<a href="https://developer.android.com/guide/topics/providers/document-provider">DocumentProvider</a> API). Encrypted files can then be selected from other applications, potentially with permanent access. This feature requires <i>"Disable volume auto-locking"</i>, and works more reliably when <i>"Keep volumes open"</i> is also enabled.</li>
|
||||||
|
<li><b>Grant write access:</b>
|
||||||
|
|
||||||
|
Files opened with another applications can be modified by them. This applies to both previous unsafe features.</li>
|
||||||
</ul>
|
</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
|
# Download
|
||||||
<a href="https://f-droid.org/packages/sushi.hardcore.droidfs">
|
<a href="https://f-droid.org/packages/sushi.hardcore.droidfs">
|
||||||
@ -83,23 +96,14 @@ F-Droid APKs should be signed with the F-Droid key. More details [here](https://
|
|||||||
# Permissions
|
# Permissions
|
||||||
DroidFS needs some permissions for certain features. However, you are free to deny them if you do not wish to use these features.
|
DroidFS needs some permissions for certain features. However, you are free to deny them if you do not wish to use these features.
|
||||||
|
|
||||||
<ul>
|
- **Read & write access to shared storage**: Required to access volumes located on shared storage.
|
||||||
<li><h4>Read & write access to shared storage:</h4>
|
- **Biometric/Fingerprint hardware**: Required to encrypt/decrypt password hashes using a fingerprint protected key.
|
||||||
Required to access volumes located on shared storage.
|
- **Camera**: Required to take encrypted photos or videos directly from the app.
|
||||||
</li>
|
- **Record audio**: Required if you want sound on video recorded with DroidFS.
|
||||||
<li><h4>Biometric/Fingerprint hardware:</h4>
|
- **Notifications**: Used to report file operations progress and notify about volumes kept open.
|
||||||
Required to encrypt/decrypt password hashes using a fingerprint protected key.
|
|
||||||
</li>
|
|
||||||
<li><h4>Camera:</h4>
|
|
||||||
Required to take encrypted photos or videos directly from the app.
|
|
||||||
</li>
|
|
||||||
<li><h4>Record audio:</h4>
|
|
||||||
Required if you want sound on video recorded with DroidFS.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
# Limitations
|
# 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.
|
Due to Android's storage restrictions, encrypted volumes located on SD cards must be placed under `/Android/data/sushi.hardcore.droidfs/` if you want DroidFS to be able to modify them.
|
||||||
|
|
||||||
|
7
TODO.md
7
TODO.md
@ -8,8 +8,9 @@ Here's a list of features that it would be nice to have in DroidFS. As this is a
|
|||||||
|
|
||||||
## UX
|
## UX
|
||||||
- File associations editor
|
- File associations editor
|
||||||
- Optional discovery before file operations
|
- Discovery before exporting
|
||||||
- Modifiable CryFS scrypt parameters
|
- Making discovery before file operations optional
|
||||||
|
- Modifiable scrypt parameters
|
||||||
- Alert dialog showing details of file operations
|
- Alert dialog showing details of file operations
|
||||||
- Internal file browser to select volumes
|
- Internal file browser to select volumes
|
||||||
|
|
||||||
@ -18,8 +19,6 @@ Here's a list of features that it would be nice to have in DroidFS. As this is a
|
|||||||
- [fscrypt](https://www.kernel.org/doc/html/latest/filesystems/fscrypt.html): filesystem encryption at the kernel level
|
- [fscrypt](https://www.kernel.org/doc/html/latest/filesystems/fscrypt.html): filesystem encryption at the kernel level
|
||||||
|
|
||||||
## Health
|
## Health
|
||||||
- F-Droid ABI split
|
|
||||||
- OpenSSL & FFmpeg as git submodules (useful for F-Droid)
|
|
||||||
- Remove all android:configChanges from AndroidManifest.xml
|
- Remove all android:configChanges from AndroidManifest.xml
|
||||||
- More efficient thumbnails cache
|
- More efficient thumbnails cache
|
||||||
- Guide for translators
|
- Guide for translators
|
||||||
|
@ -5,7 +5,8 @@ project(DroidFS)
|
|||||||
option(GOCRYPTFS "build libgocryptfs" ON)
|
option(GOCRYPTFS "build libgocryptfs" ON)
|
||||||
option(CRYFS "build libcryfs" ON)
|
option(CRYFS "build libcryfs" ON)
|
||||||
|
|
||||||
add_library(memfile SHARED src/main/native/memfile.cpp)
|
add_library(memfile SHARED src/main/native/memfile.c)
|
||||||
|
target_link_libraries(memfile log)
|
||||||
|
|
||||||
if (GOCRYPTFS)
|
if (GOCRYPTFS)
|
||||||
add_library(gocryptfs SHARED IMPORTED)
|
add_library(gocryptfs SHARED IMPORTED)
|
||||||
|
@ -13,31 +13,40 @@ if (hasProperty("disableGocryptfs")) {
|
|||||||
ext.disableGocryptfs = false
|
ext.disableGocryptfs = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasProperty("nosplits")) {
|
|
||||||
ext.splits = false
|
|
||||||
} else {
|
|
||||||
ext.splits = true
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 33
|
compileSdk 34
|
||||||
buildToolsVersion "33.0.0"
|
ndkVersion '25.2.9519653'
|
||||||
ndkVersion "25.1.8937393"
|
|
||||||
namespace "sushi.hardcore.droidfs"
|
namespace "sushi.hardcore.droidfs"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
def abiCodes = [ "x86": 1, "x86_64": 2, "armeabi-v7a": 3, "arm64-v8a": 4]
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "sushi.hardcore.droidfs"
|
applicationId "sushi.hardcore.droidfs"
|
||||||
minSdkVersion 21
|
minSdkVersion 21
|
||||||
targetSdkVersion 32
|
targetSdkVersion 34
|
||||||
versionCode 32
|
versionCode 37
|
||||||
versionName "2.0.2"
|
versionName "2.2.0"
|
||||||
|
|
||||||
ndk {
|
splits {
|
||||||
abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
|
abi {
|
||||||
|
enable true
|
||||||
|
reset() // fix unknown bug (https://ru.stackoverflow.com/questions/1557805/abis-armeabi-mips-mips64-riscv64-are-not-supported-for-platform)
|
||||||
|
if (project.hasProperty("abi")) {
|
||||||
|
include project.getProperty("abi")
|
||||||
|
} else {
|
||||||
|
abiCodes.keySet().each { abi -> include abi }
|
||||||
|
universalApk !project.hasProperty("nouniversal")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
externalNativeBuild.cmake {
|
externalNativeBuild.cmake {
|
||||||
@ -50,23 +59,25 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (project.ext.splits) {
|
applicationVariants.configureEach { variant ->
|
||||||
splits {
|
variant.resValue "string", "versionName", variant.versionName
|
||||||
abi {
|
buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}"
|
||||||
enable true
|
buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}"
|
||||||
universalApk true
|
variant.outputs.each { output ->
|
||||||
|
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
||||||
|
if (abi == null) { // universal
|
||||||
|
output.versionCodeOverride = variant.versionCode*10
|
||||||
|
output.outputFileName = "DroidFS-v${variant.versionName}-${variant.name}-universal.apk"
|
||||||
|
} else {
|
||||||
|
output.versionCodeOverride = variant.versionCode*10 + abiCodes[abi]
|
||||||
|
output.outputFileName = "DroidFS-v${variant.versionName}-${variant.name}-${abi}.apk"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applicationVariants.all { variant ->
|
|
||||||
variant.resValue "string", "versionName", variant.versionName
|
|
||||||
buildConfigField "boolean", "CRYFS_DISABLED", "${project.ext.disableCryFS}"
|
|
||||||
buildConfigField "boolean", "GOCRYPTFS_DISABLED", "${project.ext.disableGocryptfs}"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
|
buildConfig true
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@ -98,34 +109,33 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(":libpdfviewer:app")
|
implementation project(":libpdfviewer:app")
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
implementation 'androidx.core:core-ktx:1.10.0'
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||||
implementation "androidx.appcompat:appcompat:1.6.1"
|
|
||||||
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||||
def lifecycle_version = "2.6.1"
|
def lifecycle_version = "2.8.3"
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
|
||||||
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
|
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
|
||||||
|
|
||||||
implementation "androidx.sqlite:sqlite-ktx:2.3.1"
|
implementation "androidx.preference:preference-ktx:1.2.1"
|
||||||
implementation "androidx.preference:preference-ktx:1.2.0"
|
|
||||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||||
implementation 'com.google.android.material:material:1.9.0'
|
implementation 'com.google.android.material:material:1.12.0'
|
||||||
implementation 'com.github.bumptech.glide:glide:4.15.1'
|
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||||
|
|
||||||
def exoplayer_version = "2.18.6"
|
def media3_version = "1.3.1"
|
||||||
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
|
implementation "androidx.media3:media3-exoplayer:$media3_version"
|
||||||
implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
|
implementation "androidx.media3:media3-ui:$media3_version"
|
||||||
|
implementation "androidx.media3:media3-datasource:$media3_version"
|
||||||
|
|
||||||
implementation "androidx.concurrent:concurrent-futures:1.1.0"
|
def camerax_version = "1.3.4"
|
||||||
|
|
||||||
def camerax_version = "1.3.0-alpha06"
|
|
||||||
implementation "androidx.camera:camera-camera2:$camerax_version"
|
implementation "androidx.camera:camera-camera2:$camerax_version"
|
||||||
implementation "androidx.camera:camera-lifecycle:$camerax_version"
|
implementation "androidx.camera:camera-lifecycle:$camerax_version"
|
||||||
implementation "androidx.camera:camera-view:$camerax_version"
|
implementation "androidx.camera:camera-view:$camerax_version"
|
||||||
implementation "androidx.camera:camera-extensions:$camerax_version"
|
implementation "androidx.camera:camera-extensions:$camerax_version"
|
||||||
|
|
||||||
def autoValueVersion = "1.10.1"
|
// dependencies needed by CameraX patch
|
||||||
|
implementation "androidx.concurrent:concurrent-futures:1.2.0"
|
||||||
|
def autoValueVersion = '1.10.4'
|
||||||
implementation "com.google.auto.value:auto-value-annotations:$autoValueVersion"
|
implementation "com.google.auto.value:auto-value-annotations:$autoValueVersion"
|
||||||
annotationProcessor "com.google.auto.value:auto-value:$autoValueVersion"
|
annotationProcessor "com.google.auto.value:auto-value:$autoValueVersion"
|
||||||
}
|
}
|
||||||
|
1
app/ffmpeg/.gitignore
vendored
1
app/ffmpeg/.gitignore
vendored
@ -1,2 +1 @@
|
|||||||
ffmpeg
|
|
||||||
build
|
build
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
if [ -z ${ANDROID_NDK_HOME+x} ]; then
|
if [ -z ${ANDROID_NDK_HOME+x} ]; then
|
||||||
echo "Error: \$ANDROID_NDK_HOME is not defined." >&2
|
echo "Error: \$ANDROID_NDK_HOME is not defined." >&2
|
||||||
exit 1
|
exit 1
|
||||||
elif [ $# -lt 1 ]; then
|
|
||||||
echo "Usage: $0 <FFmpeg source directory> [<ABI>]" >&2
|
|
||||||
exit 1
|
|
||||||
else
|
else
|
||||||
FFMPEG_DIR=$1
|
cd "$(dirname "$0")"
|
||||||
|
FFMPEG_DIR="ffmpeg"
|
||||||
compile_for_arch() {
|
compile_for_arch() {
|
||||||
echo "Compiling for $1..."
|
echo "Compiling for $1..."
|
||||||
case $1 in
|
case $1 in
|
||||||
@ -29,7 +29,8 @@ else
|
|||||||
ARCH="arm"
|
ARCH="arm"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
(cd $FFMPEG_DIR && make clean;
|
(cd $FFMPEG_DIR
|
||||||
|
make clean || true
|
||||||
./configure \
|
./configure \
|
||||||
--cc="$CFN" \
|
--cc="$CFN" \
|
||||||
--cxx="$CFN++" \
|
--cxx="$CFN++" \
|
||||||
@ -73,22 +74,19 @@ else
|
|||||||
--disable-audiotoolbox \
|
--disable-audiotoolbox \
|
||||||
--disable-appkit \
|
--disable-appkit \
|
||||||
--disable-alsa \
|
--disable-alsa \
|
||||||
--disable-debug \
|
--disable-debug
|
||||||
>/dev/null &&
|
make -j "$(nproc --all)" >/dev/null)
|
||||||
make -j $(nproc --all) >/dev/null) &&
|
mkdir -p "build/$1/libavformat" "build/$1/libavcodec" "build/$1/libavutil"
|
||||||
mkdir -p build/$1/libavformat build/$1/libavcodec build/$1/libavutil &&
|
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so "build/$1/libavformat"
|
||||||
cp $FFMPEG_DIR/libavformat/*.h $FFMPEG_DIR/libavformat/libavformat.so build/$1/libavformat &&
|
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so "build/$1/libavcodec"
|
||||||
cp $FFMPEG_DIR/libavcodec/*.h $FFMPEG_DIR/libavcodec/libavcodec.so build/$1/libavcodec &&
|
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so "build/$1/libavutil"
|
||||||
cp $FFMPEG_DIR/libavutil/*.h $FFMPEG_DIR/libavutil/libavutil.so build/$1/libavutil ||
|
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
|
export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
|
||||||
if [ $# -eq 2 ]; then
|
if [ $# -eq 1 ]; then
|
||||||
compile_for_arch $2
|
compile_for_arch "$1"
|
||||||
else
|
else
|
||||||
declare -a ABIs=("x86_64" "x86" "arm64-v8a" "armeabi-v7a")
|
for abi in "x86_64" "x86" "arm64-v8a" "armeabi-v7a"; do
|
||||||
for abi in ${ABIs[@]}; do
|
|
||||||
compile_for_arch $abi
|
compile_for_arch $abi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
1
app/ffmpeg/ffmpeg
Submodule
1
app/ffmpeg/ffmpeg
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit af25a4bfd2503caf3ee485b27b99b620302f5718
|
@ -1 +1 @@
|
|||||||
Subproject commit 3c56f86d86afacaf4a07ae77aa3d146764d587ec
|
Subproject commit cd0af7088066f870f12eceed9836bde897f1d164
|
@ -1 +1 @@
|
|||||||
Subproject commit ab3e7886767d31f32baebcd72ebe5f098a70d65b
|
Subproject commit b221d4cf3c70edc711169a769a476802d2577b2b
|
29
app/proguard-rules.pro
vendored
29
app/proguard-rules.pro
vendored
@ -1,24 +1,4 @@
|
|||||||
# Add project specific ProGuard rules here.
|
-keepattributes SourceFile,LineNumberTable
|
||||||
# You can control the set of applied configuration files using the
|
|
||||||
# proguardFiles setting in build.gradle.
|
|
||||||
#
|
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
|
||||||
# class:
|
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
|
||||||
# debugging stack traces.
|
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
|
||||||
# hide the original source file name.
|
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
|
|
||||||
-keep class sushi.hardcore.droidfs.SettingsActivity$**
|
-keep class sushi.hardcore.droidfs.SettingsActivity$**
|
||||||
-keep class sushi.hardcore.droidfs.explorers.ExplorerElement
|
-keep class sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||||
@ -29,3 +9,10 @@
|
|||||||
void writePacket(byte[]);
|
void writePacket(byte[]);
|
||||||
void seek(long);
|
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;
|
||||||
|
}
|
||||||
|
@ -3,11 +3,10 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:installLocation="auto">
|
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.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
@ -57,27 +56,34 @@
|
|||||||
<activity android:name=".file_viewers.AudioPlayer" android:configChanges="screenSize|orientation" />
|
<activity android:name=".file_viewers.AudioPlayer" android:configChanges="screenSize|orientation" />
|
||||||
<activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" />
|
<activity android:name=".file_viewers.TextEditor" android:configChanges="screenSize|orientation" />
|
||||||
<activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
|
<activity android:name=".CameraActivity" android:screenOrientation="nosensor" />
|
||||||
|
<activity android:name=".LogcatActivity"/>
|
||||||
|
|
||||||
<service android:name=".WiperService" android:exported="false" android:stopWithTask="false"/>
|
<service android:name=".KeepAliveService" android:exported="false" android:foregroundServiceType="dataSync" />
|
||||||
<service android:name=".file_operations.FileOperationService" android:exported="false"/>
|
<service android:name=".ClosingService" android:exported="false" android:stopWithTask="false"/>
|
||||||
|
<service android:name=".file_operations.FileOperationService" android:exported="false" android:foregroundServiceType="dataSync"/>
|
||||||
|
|
||||||
<receiver android:name=".file_operations.NotificationBroadcastReceiver" android:exported="false">
|
<receiver android:name=".NotificationBroadcastReceiver" android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="file_operation_cancel"/>
|
<action android:name="file_operation_cancel"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name=".content_providers.MemoryFileProvider"
|
android:name=".content_providers.TemporaryFileProvider"
|
||||||
android:authorities="${applicationId}.memory_provider"
|
android:authorities="${applicationId}.temporary_provider"
|
||||||
android:exported="true"
|
android:exported="true"/>
|
||||||
android:writePermission="${applicationId}.WRITE_TEMPORARY_STORAGE" />
|
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name=".content_providers.DiskFileProvider"
|
android:authorities="${applicationId}.volume_provider"
|
||||||
android:authorities="${applicationId}.disk_provider"
|
android:name=".content_providers.VolumeProvider"
|
||||||
android:exported="true"
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -58,8 +58,8 @@ public final class SucklessPendingRecording {
|
|||||||
private final OutputOptions mOutputOptions;
|
private final OutputOptions mOutputOptions;
|
||||||
private Consumer<VideoRecordEvent> mEventListener;
|
private Consumer<VideoRecordEvent> mEventListener;
|
||||||
private Executor mListenerExecutor;
|
private Executor mListenerExecutor;
|
||||||
|
|
||||||
private boolean mAudioEnabled = false;
|
private boolean mAudioEnabled = false;
|
||||||
|
private boolean mIsPersistent = false;
|
||||||
|
|
||||||
SucklessPendingRecording(@NonNull Context context, @NonNull SucklessRecorder recorder,
|
SucklessPendingRecording(@NonNull Context context, @NonNull SucklessRecorder recorder,
|
||||||
@NonNull OutputOptions options) {
|
@NonNull OutputOptions options) {
|
||||||
@ -104,6 +104,10 @@ public final class SucklessPendingRecording {
|
|||||||
return mAudioEnabled;
|
return mAudioEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean isPersistent() {
|
||||||
|
return mIsPersistent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enables audio to be recorded for this recording.
|
* Enables audio to be recorded for this recording.
|
||||||
*
|
*
|
||||||
@ -139,6 +143,69 @@ public final class SucklessPendingRecording {
|
|||||||
return this;
|
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.
|
* 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
|
* <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
|
* 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
|
* @throws IllegalStateException if the associated Recorder currently has an unfinished
|
||||||
* active recording.
|
* active recording.
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
package androidx.camera.video;
|
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_DURATION_LIMIT_REACHED;
|
||||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED;
|
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED;
|
||||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED;
|
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.RestrictTo;
|
||||||
import androidx.annotation.VisibleForTesting;
|
import androidx.annotation.VisibleForTesting;
|
||||||
import androidx.camera.core.AspectRatio;
|
import androidx.camera.core.AspectRatio;
|
||||||
|
import androidx.camera.core.CameraInfo;
|
||||||
|
import androidx.camera.core.DynamicRange;
|
||||||
import androidx.camera.core.Logger;
|
import androidx.camera.core.Logger;
|
||||||
import androidx.camera.core.SurfaceRequest;
|
import androidx.camera.core.SurfaceRequest;
|
||||||
import androidx.camera.core.impl.MutableStateObservable;
|
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.DeactivateEncoderSurfaceBeforeStopEncoderQuirk;
|
||||||
import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
|
import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
|
||||||
import androidx.camera.video.internal.compat.quirk.EncoderNotUsePersistentInputSurfaceQuirk;
|
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.AudioEncoderConfig;
|
||||||
import androidx.camera.video.internal.encoder.BufferCopiedEncodedData;
|
import androidx.camera.video.internal.encoder.BufferCopiedEncodedData;
|
||||||
import androidx.camera.video.internal.encoder.EncodeException;
|
import androidx.camera.video.internal.encoder.EncodeException;
|
||||||
@ -340,10 +343,14 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// Members only accessed on mSequentialExecutor //
|
// Members only accessed on mSequentialExecutor //
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
private RecordingRecord mInProgressRecording = null;
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
|
RecordingRecord mInProgressRecording = null;
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
boolean mInProgressRecordingStopping = false;
|
boolean mInProgressRecordingStopping = false;
|
||||||
private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null;
|
@Nullable
|
||||||
|
private SurfaceRequest.TransformationInfo mInProgressTransformationInfo = null;
|
||||||
|
@Nullable
|
||||||
|
private SurfaceRequest.TransformationInfo mSourceTransformationInfo = null;
|
||||||
private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null;
|
private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null;
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
final List<ListenableFuture<Void>> mEncodingFutures = new ArrayList<>();
|
final List<ListenableFuture<Void>> mEncodingFutures = new ArrayList<>();
|
||||||
@ -424,13 +431,15 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
ScheduledFuture<?> mSourceNonStreamingTimeout = null;
|
ScheduledFuture<?> mSourceNonStreamingTimeout = null;
|
||||||
// The Recorder has to be reset first before being configured again.
|
// The Recorder has to be reset first before being configured again.
|
||||||
private boolean mNeedsReset = false;
|
private boolean mNeedsResetBeforeNextStart = false;
|
||||||
@NonNull
|
@NonNull
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
VideoEncoderSession mVideoEncoderSession;
|
VideoEncoderSession mVideoEncoderSession;
|
||||||
@Nullable
|
@Nullable
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
VideoEncoderSession mVideoEncoderSessionToRelease = null;
|
VideoEncoderSession mVideoEncoderSessionToRelease = null;
|
||||||
|
double mAudioAmplitude = 0;
|
||||||
|
private boolean mShouldSendResumeEvent = false;
|
||||||
//--------------------------------------------------------------------------------------------//
|
//--------------------------------------------------------------------------------------------//
|
||||||
|
|
||||||
SucklessRecorder(@Nullable Executor executor, @NonNull MediaSpec mediaSpec,
|
SucklessRecorder(@Nullable Executor executor, @NonNull MediaSpec mediaSpec,
|
||||||
@ -487,6 +496,13 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
mSequentialExecutor.execute(() -> onSourceStateChangedInternal(newState));
|
mSequentialExecutor.execute(() -> onSourceStateChangedInternal(newState));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RestrictTo(RestrictTo.Scope.LIBRARY)
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public VideoCapabilities getMediaCapabilities(@NonNull CameraInfo cameraInfo) {
|
||||||
|
return getVideoCapabilities(cameraInfo);
|
||||||
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
public SucklessPendingRecording prepareRecording(@NonNull Context context, @NonNull MuxerOutputOptions outputOptions) {
|
public SucklessPendingRecording prepareRecording(@NonNull Context context, @NonNull MuxerOutputOptions outputOptions) {
|
||||||
return prepareRecordingInternal(context, 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;
|
RecordingRecord pendingRecordingToFinalize = null;
|
||||||
synchronized (mLock) {
|
synchronized (mLock) {
|
||||||
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
|
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
|
||||||
@ -801,7 +818,7 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
|
long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
|
||||||
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
|
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
|
||||||
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
|
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
|
||||||
explicitlyStopTimeUs, ERROR_NONE, null));
|
explicitlyStopTimeUs, error, errorCause));
|
||||||
break;
|
break;
|
||||||
case ERROR:
|
case ERROR:
|
||||||
// In an error state, the recording will already be finalized. Treat as a
|
// 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 (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,
|
finalizePendingRecording(pendingRecordingToFinalize, ERROR_NO_VALID_DATA,
|
||||||
new RuntimeException("Recording was stopped before any data could be "
|
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(),
|
recordingToFinalize.getOutputOptions(),
|
||||||
RecordingStats.of(/*duration=*/0L,
|
RecordingStats.of(/*duration=*/0L,
|
||||||
/*bytes=*/0L,
|
/*bytes=*/0L,
|
||||||
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause)),
|
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause,
|
||||||
|
AUDIO_AMPLITUDE_NONE)),
|
||||||
OutputResults.of(Uri.EMPTY),
|
OutputResults.of(Uri.EMPTY),
|
||||||
error,
|
error,
|
||||||
cause));
|
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.
|
// 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
|
// Otherwise, we'll wait for the active surface's surface request listener to
|
||||||
// reset the encoder.
|
// reset the encoder.
|
||||||
requestReset(ERROR_SOURCE_INACTIVE, null);
|
requestReset(ERROR_SOURCE_INACTIVE, null, false);
|
||||||
} else {
|
} else {
|
||||||
// The source becomes inactive, the incoming new surface request has to be cached
|
// 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
|
// and be serviced after the Recorder is reset when receiving the previous
|
||||||
// surface request complete callback.
|
// surface request complete callback.
|
||||||
mNeedsReset = true;
|
mNeedsResetBeforeNextStart = true;
|
||||||
if (mInProgressRecording != null) {
|
if (mInProgressRecording != null && !mInProgressRecording.isPersistent()) {
|
||||||
// Stop any in progress recording with "source inactive" error
|
// Stop the in progress recording with "source inactive" error if it's not a
|
||||||
|
// persistent recording.
|
||||||
onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE,
|
onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
@ -905,7 +928,8 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
* the surface request complete callback first.
|
* the surface request complete callback first.
|
||||||
*/
|
*/
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause) {
|
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause,
|
||||||
|
boolean videoOnly) {
|
||||||
boolean shouldReset = false;
|
boolean shouldReset = false;
|
||||||
boolean shouldStop = false;
|
boolean shouldStop = false;
|
||||||
synchronized (mLock) {
|
synchronized (mLock) {
|
||||||
@ -927,14 +951,22 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
case PAUSED:
|
case PAUSED:
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case RECORDING:
|
case RECORDING:
|
||||||
|
Preconditions.checkState(mInProgressRecording != null, "In-progress recording"
|
||||||
|
+ " shouldn't be null when in state " + mState);
|
||||||
if (mActiveRecordingRecord != mInProgressRecording) {
|
if (mActiveRecordingRecord != mInProgressRecording) {
|
||||||
throw new AssertionError("In-progress recording does not match the active"
|
throw new AssertionError("In-progress recording does not match the active"
|
||||||
+ " recording. Unable to reset encoder.");
|
+ " recording. Unable to reset encoder.");
|
||||||
}
|
}
|
||||||
// If there's an active recording, stop it first then release the resources
|
// If there's an active persistent recording, reset the Recorder directly.
|
||||||
// at onRecordingFinalized().
|
// Otherwise, stop the recording first then release the Recorder at
|
||||||
shouldStop = true;
|
// onRecordingFinalized().
|
||||||
// Fall-through
|
if (isPersistentRecordingInProgress()) {
|
||||||
|
shouldReset = true;
|
||||||
|
} else {
|
||||||
|
shouldStop = true;
|
||||||
|
setState(State.RESETTING);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case STOPPING:
|
case STOPPING:
|
||||||
// Already stopping. Set state to RESETTING so resources will be released once
|
// Already stopping. Set state to RESETTING so resources will be released once
|
||||||
// onRecordingFinalized() runs.
|
// 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
|
// 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.
|
// the sequential executor and the state changes above are correctly handled.
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
reset();
|
if (videoOnly) {
|
||||||
|
resetVideo();
|
||||||
|
} else {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
} else if (shouldStop) {
|
} else if (shouldStop) {
|
||||||
stopInternal(mInProgressRecording, Encoder.NO_TIMESTAMP, errorCode, errorCause);
|
stopInternal(mInProgressRecording, Encoder.NO_TIMESTAMP, errorCode, errorCause);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
|
|
||||||
private void configureInternal(@NonNull SurfaceRequest surfaceRequest,
|
private void configureInternal(@NonNull SurfaceRequest surfaceRequest,
|
||||||
@NonNull Timebase videoSourceTimebase) {
|
@NonNull Timebase videoSourceTimebase) {
|
||||||
if (surfaceRequest.isServiced()) {
|
if (surfaceRequest.isServiced()) {
|
||||||
@ -964,16 +999,19 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
|
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
|
||||||
(transformationInfo) -> mSurfaceTransformationInfo = transformationInfo);
|
(transformationInfo) -> mSourceTransformationInfo = transformationInfo);
|
||||||
Size surfaceSize = surfaceRequest.getResolution();
|
Size surfaceSize = surfaceRequest.getResolution();
|
||||||
// Fetch and cache nearest encoder profiles, if one exists.
|
// Fetch and cache nearest encoder profiles, if one exists.
|
||||||
LegacyVideoCapabilities capabilities =
|
DynamicRange dynamicRange = surfaceRequest.getDynamicRange();
|
||||||
LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo());
|
VideoCapabilities capabilities = getVideoCapabilities(
|
||||||
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize);
|
surfaceRequest.getCamera().getCameraInfo());
|
||||||
|
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize,
|
||||||
|
dynamicRange);
|
||||||
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
|
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
|
||||||
+ " for surface size " + surfaceSize);
|
+ " for surface size " + surfaceSize);
|
||||||
if (highestSupportedQuality != Quality.NONE) {
|
if (highestSupportedQuality != Quality.NONE) {
|
||||||
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality);
|
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality,
|
||||||
|
dynamicRange);
|
||||||
if (mResolvedEncoderProfiles == null) {
|
if (mResolvedEncoderProfiles == null) {
|
||||||
throw new AssertionError("Camera advertised available quality but did not "
|
throw new AssertionError("Camera advertised available quality but did not "
|
||||||
+ "produce EncoderProfiles for advertised quality.");
|
+ "produce EncoderProfiles for advertised quality.");
|
||||||
@ -986,9 +1024,14 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
private void setupVideo(@NonNull SurfaceRequest request, @NonNull Timebase timebase) {
|
private void setupVideo(@NonNull SurfaceRequest request, @NonNull Timebase timebase) {
|
||||||
safeToCloseVideoEncoder().addListener(() -> {
|
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: "
|
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;
|
return;
|
||||||
}
|
}
|
||||||
VideoEncoderSession videoEncoderSession =
|
VideoEncoderSession videoEncoderSession =
|
||||||
@ -1020,6 +1063,12 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
}, mSequentialExecutor);
|
}, mSequentialExecutor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
|
@ExecutedBy("mSequentialExecutor")
|
||||||
|
boolean isPersistentRecordingInProgress() {
|
||||||
|
return mInProgressRecording != null && mInProgressRecording.isPersistent();
|
||||||
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
private ListenableFuture<Void> safeToCloseVideoEncoder() {
|
private ListenableFuture<Void> safeToCloseVideoEncoder() {
|
||||||
@ -1055,7 +1104,9 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
|
|
||||||
mVideoEncoderSessionToRelease = videoEncoderSession;
|
mVideoEncoderSessionToRelease = videoEncoderSession;
|
||||||
setLatestSurface(null);
|
setLatestSurface(null);
|
||||||
requestReset(ERROR_SOURCE_INACTIVE, null);
|
// Only reset video if the in-progress recording is persistent.
|
||||||
|
requestReset(ERROR_SOURCE_INACTIVE, null,
|
||||||
|
isPersistentRecordingInProgress());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -1070,17 +1121,14 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
void onConfigured() {
|
void onConfigured() {
|
||||||
RecordingRecord recordingToStart = null;
|
RecordingRecord recordingToStart = null;
|
||||||
RecordingRecord pendingRecordingToFinalize = null;
|
RecordingRecord pendingRecordingToFinalize = null;
|
||||||
|
boolean continuePersistentRecording = false;
|
||||||
@VideoRecordError int error = ERROR_NONE;
|
@VideoRecordError int error = ERROR_NONE;
|
||||||
Throwable errorCause = null;
|
Throwable errorCause = null;
|
||||||
boolean startRecordingPaused = false;
|
boolean recordingPaused = false;
|
||||||
synchronized (mLock) {
|
synchronized (mLock) {
|
||||||
switch (mState) {
|
switch (mState) {
|
||||||
case IDLING:
|
case IDLING:
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case RECORDING:
|
|
||||||
// Fall-through
|
|
||||||
case PAUSED:
|
|
||||||
// Fall-through
|
|
||||||
case RESETTING:
|
case RESETTING:
|
||||||
throw new AssertionError(
|
throw new AssertionError(
|
||||||
"Incorrectly invoke onConfigured() in state " + mState);
|
"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.");
|
+ "STOPPING state when it's not waiting for a new surface.");
|
||||||
}
|
}
|
||||||
break;
|
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:
|
case CONFIGURING:
|
||||||
setState(State.IDLING);
|
setState(State.IDLING);
|
||||||
break;
|
break;
|
||||||
@ -1098,7 +1155,7 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
"onConfigured() was invoked when the Recorder had encountered error");
|
"onConfigured() was invoked when the Recorder had encountered error");
|
||||||
break;
|
break;
|
||||||
case PENDING_PAUSED:
|
case PENDING_PAUSED:
|
||||||
startRecordingPaused = true;
|
recordingPaused = true;
|
||||||
// Fall through
|
// Fall through
|
||||||
case PENDING_RECORDING:
|
case PENDING_RECORDING:
|
||||||
if (mActiveRecordingRecord != null) {
|
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).
|
// Start new active recording inline on sequential executor (but unlocked).
|
||||||
startRecording(recordingToStart, startRecordingPaused);
|
startRecording(recordingToStart, recordingPaused);
|
||||||
} else if (pendingRecordingToFinalize != null) {
|
} else if (pendingRecordingToFinalize != null) {
|
||||||
finalizePendingRecording(pendingRecordingToFinalize, error, errorCause);
|
finalizePendingRecording(pendingRecordingToFinalize, error, errorCause);
|
||||||
}
|
}
|
||||||
@ -1162,7 +1231,7 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
throws AudioSourceAccessException, InvalidConfigException {
|
throws AudioSourceAccessException, InvalidConfigException {
|
||||||
MediaSpec mediaSpec = getObservableData(mMediaSpec);
|
MediaSpec mediaSpec = getObservableData(mMediaSpec);
|
||||||
// Resolve the audio mime info
|
// Resolve the audio mime info
|
||||||
MimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
|
AudioMimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
|
||||||
Timebase audioSourceTimebase = Timebase.UPTIME;
|
Timebase audioSourceTimebase = Timebase.UPTIME;
|
||||||
|
|
||||||
// Select and create the audio source
|
// Select and create the audio source
|
||||||
@ -1313,8 +1382,10 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mSurfaceTransformationInfo != null) {
|
SurfaceRequest.TransformationInfo transformationInfo = mSourceTransformationInfo;
|
||||||
mediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees());
|
if (transformationInfo != null) {
|
||||||
|
setInProgressTransformationInfo(transformationInfo);
|
||||||
|
mediaMuxer.setOrientationHint(transformationInfo.getRotationDegrees());
|
||||||
}
|
}
|
||||||
|
|
||||||
mVideoTrackIndex = mediaMuxer.addTrack(mVideoOutputConfig.getMediaFormat());
|
mVideoTrackIndex = mediaMuxer.addTrack(mVideoOutputConfig.getMediaFormat());
|
||||||
@ -1402,7 +1473,9 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
"The Recorder doesn't support recording with audio");
|
"The Recorder doesn't support recording with audio");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setupAudio(recordingToStart);
|
if (!mInProgressRecording.isPersistent() || mAudioEncoder == null) {
|
||||||
|
setupAudio(recordingToStart);
|
||||||
|
}
|
||||||
setAudioState(AudioState.ENABLED);
|
setAudioState(AudioState.ENABLED);
|
||||||
} catch (AudioSourceAccessException | InvalidConfigException e) {
|
} catch (AudioSourceAccessException | InvalidConfigException e) {
|
||||||
Logger.e(TAG, "Unable to create audio resource with error: ", e);
|
Logger.e(TAG, "Unable to create audio resource with error: ", e);
|
||||||
@ -1419,7 +1492,7 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
initEncoderAndAudioSourceCallbacks(recordingToStart);
|
updateEncoderCallbacks(recordingToStart, false);
|
||||||
if (isAudioEnabled()) {
|
if (isAudioEnabled()) {
|
||||||
mAudioSource.start(recordingToStart.isMuted());
|
mAudioSource.start(recordingToStart.isMuted());
|
||||||
mAudioEncoder.start();
|
mAudioEncoder.start();
|
||||||
@ -1432,7 +1505,17 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@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(
|
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
||||||
completer -> {
|
completer -> {
|
||||||
mVideoEncoder.setEncoderCallback(new EncoderCallback() {
|
mVideoEncoder.setEncoderCallback(new EncoderCallback() {
|
||||||
@ -1528,7 +1611,7 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
return "videoEncodingFuture";
|
return "videoEncodingFuture";
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (isAudioEnabled()) {
|
if (isAudioEnabled() && !videoOnly) {
|
||||||
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
||||||
completer -> {
|
completer -> {
|
||||||
Consumer<Throwable> audioErrorConsumer = throwable -> {
|
Consumer<Throwable> audioErrorConsumer = throwable -> {
|
||||||
@ -1568,6 +1651,11 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
audioErrorConsumer.accept(throwable);
|
audioErrorConsumer.accept(throwable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAmplitudeValue(double maxAmplitude) {
|
||||||
|
mAudioAmplitude = maxAmplitude;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mAudioEncoder.setEncoderCallback(new EncoderCallback() {
|
mAudioEncoder.setEncoderCallback(new EncoderCallback() {
|
||||||
@ -1654,12 +1742,16 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onFailure(@NonNull Throwable t) {
|
public void onFailure(@NonNull Throwable t) {
|
||||||
Logger.d(TAG, "Encodings end with error: " + t);
|
Preconditions.checkState(mInProgressRecording != null,
|
||||||
// If the media muxer hasn't been set up, assume the encoding fails
|
"In-progress recording shouldn't be null");
|
||||||
// because of no valid data has been produced.
|
// If a persistent recording requires reconfiguring the video encoder,
|
||||||
finalizeInProgressRecording(
|
// the previous encoder future has to be canceled without finalizing the
|
||||||
mMediaMuxer == null ? ERROR_NO_VALID_DATA : ERROR_ENCODING_FAILED,
|
// in-progress recording.
|
||||||
t);
|
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
|
// Can use direct executor since completers are always completed on sequential
|
||||||
@ -1800,11 +1892,20 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
if (isAudioEnabled()) {
|
if (isAudioEnabled()) {
|
||||||
mAudioEncoder.start();
|
mAudioEncoder.start();
|
||||||
}
|
}
|
||||||
mVideoEncoder.start();
|
// If a persistent recording is resumed immediately after the VideoCapture is rebound
|
||||||
|
// to a camera, it's possible that the encoder hasn't been created yet. Then the
|
||||||
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume(
|
// encoder will be started once it's initialized. So only start the encoder when it's
|
||||||
mInProgressRecording.getOutputOptions(),
|
// not null.
|
||||||
getInProgressRecordingStats()));
|
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;
|
mAudioEncoder = null;
|
||||||
mAudioOutputConfig = null;
|
mAudioOutputConfig = null;
|
||||||
}
|
}
|
||||||
tryReleaseVideoEncoder();
|
|
||||||
if (mAudioSource != null) {
|
if (mAudioSource != null) {
|
||||||
releaseCurrentAudioSource();
|
releaseCurrentAudioSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAudioState(AudioState.INITIALIZING);
|
setAudioState(AudioState.INITIALIZING);
|
||||||
onReset();
|
resetVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("FutureReturnValueIgnored")
|
@SuppressWarnings("FutureReturnValueIgnored")
|
||||||
@ -1926,7 +2026,8 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
private void onReset() {
|
private void onResetVideo() {
|
||||||
|
boolean shouldConfigure = true;
|
||||||
synchronized (mLock) {
|
synchronized (mLock) {
|
||||||
switch (mState) {
|
switch (mState) {
|
||||||
case PENDING_PAUSED:
|
case PENDING_PAUSED:
|
||||||
@ -1939,6 +2040,10 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
case PAUSED:
|
case PAUSED:
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case RECORDING:
|
case RECORDING:
|
||||||
|
if (isPersistentRecordingInProgress()) {
|
||||||
|
shouldConfigure = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case IDLING:
|
case IDLING:
|
||||||
// Fall-through
|
// 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 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);
|
configureInternal(mLatestSurfaceRequest, mVideoSourceTimebase);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExecutedBy("mSequentialExecutor")
|
||||||
|
private void resetVideo() {
|
||||||
|
if (mVideoEncoder != null) {
|
||||||
|
Logger.d(TAG, "Releasing video encoder.");
|
||||||
|
tryReleaseVideoEncoder();
|
||||||
|
}
|
||||||
|
onResetVideo();
|
||||||
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
private int internalAudioStateToAudioStatsState(@NonNull AudioState audioState) {
|
private int internalAudioStateToAudioStatsState(@NonNull AudioState audioState) {
|
||||||
switch (audioState) {
|
switch (audioState) {
|
||||||
@ -2063,7 +2178,9 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
mRecordingStopError = ERROR_UNKNOWN;
|
mRecordingStopError = ERROR_UNKNOWN;
|
||||||
mRecordingStopErrorCause = null;
|
mRecordingStopErrorCause = null;
|
||||||
mAudioErrorCause = null;
|
mAudioErrorCause = null;
|
||||||
|
mAudioAmplitude = AUDIO_AMPLITUDE_NONE;
|
||||||
clearPendingAudioRingBuffer();
|
clearPendingAudioRingBuffer();
|
||||||
|
setInProgressTransformationInfo(null);
|
||||||
|
|
||||||
switch (mAudioState) {
|
switch (mAudioState) {
|
||||||
case IDLING:
|
case IDLING:
|
||||||
@ -2246,7 +2363,7 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
startRecordingPaused = true;
|
startRecordingPaused = true;
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case PENDING_RECORDING:
|
case PENDING_RECORDING:
|
||||||
if (mActiveRecordingRecord != null || mNeedsReset) {
|
if (mActiveRecordingRecord != null || mNeedsResetBeforeNextStart) {
|
||||||
// Active recording is still finalizing or the Recorder is expected to be
|
// Active recording is still finalizing or the Recorder is expected to be
|
||||||
// reset. Pending recording will be serviced in onRecordingFinalized() or
|
// reset. Pending recording will be serviced in onRecordingFinalized() or
|
||||||
// in onReset().
|
// in onReset().
|
||||||
@ -2361,7 +2478,8 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
@NonNull
|
@NonNull
|
||||||
RecordingStats getInProgressRecordingStats() {
|
RecordingStats getInProgressRecordingStats() {
|
||||||
return RecordingStats.of(mRecordingDurationNs, mRecordingBytes,
|
return RecordingStats.of(mRecordingDurationNs, mRecordingBytes,
|
||||||
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause));
|
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause,
|
||||||
|
mAudioAmplitude));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
@ -2415,7 +2533,7 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
if (streamState == null) {
|
if (streamState == null) {
|
||||||
streamState = internalStateToStreamState(mState);
|
streamState = internalStateToStreamState(mState);
|
||||||
}
|
}
|
||||||
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState));
|
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState, mInProgressTransformationInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
@ -2437,7 +2555,20 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
}
|
}
|
||||||
Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + streamId);
|
Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + 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) {
|
if (mNonPendingState != state) {
|
||||||
mNonPendingState = state;
|
mNonPendingState = state;
|
||||||
mStreamInfo.setState(
|
mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(state),
|
||||||
StreamInfo.of(mStreamId, internalStateToStreamState(state)));
|
mInProgressTransformationInfo));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2509,6 +2640,21 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
return defaultMuxerFormat;
|
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
|
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
|
||||||
@AutoValue
|
@AutoValue
|
||||||
abstract static class RecordingRecord implements AutoCloseable {
|
abstract static class RecordingRecord implements AutoCloseable {
|
||||||
@ -2537,6 +2683,7 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
pendingRecording.getListenerExecutor(),
|
pendingRecording.getListenerExecutor(),
|
||||||
pendingRecording.getEventListener(),
|
pendingRecording.getEventListener(),
|
||||||
pendingRecording.isAudioEnabled(),
|
pendingRecording.isAudioEnabled(),
|
||||||
|
pendingRecording.isPersistent(),
|
||||||
recordingId
|
recordingId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2552,6 +2699,8 @@ public final class SucklessRecorder implements VideoOutput {
|
|||||||
|
|
||||||
abstract boolean hasAudioEnabled();
|
abstract boolean hasAudioEnabled();
|
||||||
|
|
||||||
|
abstract boolean isPersistent();
|
||||||
|
|
||||||
abstract long getRecordingId();
|
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"
|
throw new AssertionError("One-time media muxer creation has already occurred for"
|
||||||
+ " recording " + this);
|
+ " 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -21,6 +21,7 @@ import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
|
|||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
import androidx.annotation.RestrictTo;
|
import androidx.annotation.RestrictTo;
|
||||||
import androidx.camera.core.impl.utils.CloseGuardHelper;
|
import androidx.camera.core.impl.utils.CloseGuardHelper;
|
||||||
@ -56,13 +57,15 @@ public final class SucklessRecording implements AutoCloseable {
|
|||||||
private final SucklessRecorder mRecorder;
|
private final SucklessRecorder mRecorder;
|
||||||
private final long mRecordingId;
|
private final long mRecordingId;
|
||||||
private final OutputOptions mOutputOptions;
|
private final OutputOptions mOutputOptions;
|
||||||
|
private final boolean mIsPersistent;
|
||||||
private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
|
private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
|
||||||
|
|
||||||
SucklessRecording(@NonNull SucklessRecorder recorder, long recordingId, @NonNull OutputOptions options,
|
SucklessRecording(@NonNull SucklessRecorder recorder, long recordingId, @NonNull OutputOptions options,
|
||||||
boolean finalizedOnCreation) {
|
boolean isPersistent, boolean finalizedOnCreation) {
|
||||||
mRecorder = recorder;
|
mRecorder = recorder;
|
||||||
mRecordingId = recordingId;
|
mRecordingId = recordingId;
|
||||||
mOutputOptions = options;
|
mOutputOptions = options;
|
||||||
|
mIsPersistent = isPersistent;
|
||||||
|
|
||||||
if (finalizedOnCreation) {
|
if (finalizedOnCreation) {
|
||||||
mIsClosed.set(true);
|
mIsClosed.set(true);
|
||||||
@ -83,6 +86,7 @@ public final class SucklessRecording implements AutoCloseable {
|
|||||||
return new SucklessRecording(pendingRecording.getRecorder(),
|
return new SucklessRecording(pendingRecording.getRecorder(),
|
||||||
recordingId,
|
recordingId,
|
||||||
pendingRecording.getOutputOptions(),
|
pendingRecording.getOutputOptions(),
|
||||||
|
pendingRecording.isPersistent(),
|
||||||
/*finalizedOnCreation=*/false);
|
/*finalizedOnCreation=*/false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,6 +107,7 @@ public final class SucklessRecording implements AutoCloseable {
|
|||||||
return new SucklessRecording(pendingRecording.getRecorder(),
|
return new SucklessRecording(pendingRecording.getRecorder(),
|
||||||
recordingId,
|
recordingId,
|
||||||
pendingRecording.getOutputOptions(),
|
pendingRecording.getOutputOptions(),
|
||||||
|
pendingRecording.isPersistent(),
|
||||||
/*finalizedOnCreation=*/true);
|
/*finalizedOnCreation=*/true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,6 +116,20 @@ public final class SucklessRecording implements AutoCloseable {
|
|||||||
return mOutputOptions;
|
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.
|
* Pauses the current recording if active.
|
||||||
*
|
*
|
||||||
@ -196,11 +215,7 @@ public final class SucklessRecording implements AutoCloseable {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
mCloseGuard.close();
|
stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null);
|
||||||
if (mIsClosed.getAndSet(true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mRecorder.stop(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -208,7 +223,8 @@ public final class SucklessRecording implements AutoCloseable {
|
|||||||
protected void finalize() throws Throwable {
|
protected void finalize() throws Throwable {
|
||||||
try {
|
try {
|
||||||
mCloseGuard.warnIfOpen();
|
mCloseGuard.warnIfOpen();
|
||||||
stop();
|
stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED,
|
||||||
|
new RuntimeException("Recording stopped due to being garbage collected."));
|
||||||
} finally {
|
} finally {
|
||||||
super.finalize();
|
super.finalize();
|
||||||
}
|
}
|
||||||
@ -234,5 +250,14 @@ public final class SucklessRecording implements AutoCloseable {
|
|||||||
public boolean isClosed() {
|
public boolean isClosed() {
|
||||||
return mIsClosed.get();
|
return mIsClosed.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void stopWithError(@VideoRecordEvent.Finalize.VideoRecordError int error,
|
||||||
|
@Nullable Throwable errorCause) {
|
||||||
|
mCloseGuard.close();
|
||||||
|
if (mIsClosed.getAndSet(true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mRecorder.stop(this, error, errorCause);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,6 +35,7 @@ import android.media.MediaCodec.BufferInfo;
|
|||||||
import android.media.MediaCodecInfo;
|
import android.media.MediaCodecInfo;
|
||||||
import android.media.MediaFormat;
|
import android.media.MediaFormat;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.os.SystemClock;
|
||||||
import android.util.Range;
|
import android.util.Range;
|
||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
|
|
||||||
@ -1054,6 +1055,7 @@ public class SucklessEncoderImpl implements Encoder {
|
|||||||
if (mIsVideoEncoder) {
|
if (mIsVideoEncoder) {
|
||||||
Timebase inputTimebase;
|
Timebase inputTimebase;
|
||||||
if (DeviceQuirks.get(CameraUseInconsistentTimebaseQuirk.class) != null) {
|
if (DeviceQuirks.get(CameraUseInconsistentTimebaseQuirk.class) != null) {
|
||||||
|
Logger.w(mTag, "CameraUseInconsistentTimebaseQuirk is enabled");
|
||||||
inputTimebase = null;
|
inputTimebase = null;
|
||||||
} else {
|
} else {
|
||||||
inputTimebase = mInputTimebase;
|
inputTimebase = mInputTimebase;
|
||||||
@ -1065,7 +1067,7 @@ public class SucklessEncoderImpl implements Encoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onInputBufferAvailable(MediaCodec mediaCodec, int index) {
|
public void onInputBufferAvailable(@NonNull MediaCodec mediaCodec, int index) {
|
||||||
mEncoderExecutor.execute(() -> {
|
mEncoderExecutor.execute(() -> {
|
||||||
if (mStopped) {
|
if (mStopped) {
|
||||||
Logger.w(mTag, "Receives input frame after codec is reset.");
|
Logger.w(mTag, "Receives input frame after codec is reset.");
|
||||||
@ -1131,6 +1133,15 @@ public class SucklessEncoderImpl implements Encoder {
|
|||||||
if (checkBufferInfo(bufferInfo)) {
|
if (checkBufferInfo(bufferInfo)) {
|
||||||
if (!mHasFirstData) {
|
if (!mHasFirstData) {
|
||||||
mHasFirstData = true;
|
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);
|
BufferInfo outBufferInfo = resolveOutputBufferInfo(bufferInfo);
|
||||||
mLastSentAdjustedTimeUs = outBufferInfo.presentationTimeUs;
|
mLastSentAdjustedTimeUs = outBufferInfo.presentationTimeUs;
|
||||||
|
@ -5,7 +5,7 @@ Create the `new` folder if needed:
|
|||||||
mkdir -p new
|
mkdir -p new
|
||||||
```
|
```
|
||||||
|
|
||||||
Put new CameraX files from upstream in the `new` folder.
|
Put the new CameraX files from upstream (`androidx.camera.video.Recorder`, `androidx.camera.video.Recording`, `androidx.camera.video.PendingRecording` and `androidx.camera.video.internal.encoder.EncoderImpl`) in the `new` folder.
|
||||||
|
|
||||||
Perform the 3 way merge:
|
Perform the 3 way merge:
|
||||||
```
|
```
|
||||||
|
@ -35,6 +35,7 @@ import android.media.MediaCodec.BufferInfo;
|
|||||||
import android.media.MediaCodecInfo;
|
import android.media.MediaCodecInfo;
|
||||||
import android.media.MediaFormat;
|
import android.media.MediaFormat;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.os.SystemClock;
|
||||||
import android.util.Range;
|
import android.util.Range;
|
||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
|
|
||||||
@ -1053,6 +1054,7 @@ public class EncoderImpl implements Encoder {
|
|||||||
if (mIsVideoEncoder) {
|
if (mIsVideoEncoder) {
|
||||||
Timebase inputTimebase;
|
Timebase inputTimebase;
|
||||||
if (DeviceQuirks.get(CameraUseInconsistentTimebaseQuirk.class) != null) {
|
if (DeviceQuirks.get(CameraUseInconsistentTimebaseQuirk.class) != null) {
|
||||||
|
Logger.w(mTag, "CameraUseInconsistentTimebaseQuirk is enabled");
|
||||||
inputTimebase = null;
|
inputTimebase = null;
|
||||||
} else {
|
} else {
|
||||||
inputTimebase = mInputTimebase;
|
inputTimebase = mInputTimebase;
|
||||||
@ -1064,7 +1066,7 @@ public class EncoderImpl implements Encoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onInputBufferAvailable(MediaCodec mediaCodec, int index) {
|
public void onInputBufferAvailable(@NonNull MediaCodec mediaCodec, int index) {
|
||||||
mEncoderExecutor.execute(() -> {
|
mEncoderExecutor.execute(() -> {
|
||||||
if (mStopped) {
|
if (mStopped) {
|
||||||
Logger.w(mTag, "Receives input frame after codec is reset.");
|
Logger.w(mTag, "Receives input frame after codec is reset.");
|
||||||
@ -1130,6 +1132,15 @@ public class EncoderImpl implements Encoder {
|
|||||||
if (checkBufferInfo(bufferInfo)) {
|
if (checkBufferInfo(bufferInfo)) {
|
||||||
if (!mHasFirstData) {
|
if (!mHasFirstData) {
|
||||||
mHasFirstData = true;
|
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);
|
BufferInfo outBufferInfo = resolveOutputBufferInfo(bufferInfo);
|
||||||
mLastSentAdjustedTimeUs = outBufferInfo.presentationTimeUs;
|
mLastSentAdjustedTimeUs = outBufferInfo.presentationTimeUs;
|
||||||
|
@ -56,8 +56,8 @@ public final class PendingRecording {
|
|||||||
private final OutputOptions mOutputOptions;
|
private final OutputOptions mOutputOptions;
|
||||||
private Consumer<VideoRecordEvent> mEventListener;
|
private Consumer<VideoRecordEvent> mEventListener;
|
||||||
private Executor mListenerExecutor;
|
private Executor mListenerExecutor;
|
||||||
|
|
||||||
private boolean mAudioEnabled = false;
|
private boolean mAudioEnabled = false;
|
||||||
|
private boolean mIsPersistent = false;
|
||||||
|
|
||||||
PendingRecording(@NonNull Context context, @NonNull Recorder recorder,
|
PendingRecording(@NonNull Context context, @NonNull Recorder recorder,
|
||||||
@NonNull OutputOptions options) {
|
@NonNull OutputOptions options) {
|
||||||
@ -102,6 +102,10 @@ public final class PendingRecording {
|
|||||||
return mAudioEnabled;
|
return mAudioEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean isPersistent() {
|
||||||
|
return mIsPersistent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enables audio to be recorded for this recording.
|
* Enables audio to be recorded for this recording.
|
||||||
*
|
*
|
||||||
@ -137,6 +141,69 @@ public final class PendingRecording {
|
|||||||
return this;
|
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.
|
* 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
|
* <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
|
* 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
|
* @throws IllegalStateException if the associated Recorder currently has an unfinished
|
||||||
* active recording.
|
* active recording.
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
package androidx.camera.video;
|
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_DURATION_LIMIT_REACHED;
|
||||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED;
|
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED;
|
||||||
import static androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED;
|
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.RestrictTo;
|
||||||
import androidx.annotation.VisibleForTesting;
|
import androidx.annotation.VisibleForTesting;
|
||||||
import androidx.camera.core.AspectRatio;
|
import androidx.camera.core.AspectRatio;
|
||||||
|
import androidx.camera.core.CameraInfo;
|
||||||
|
import androidx.camera.core.DynamicRange;
|
||||||
import androidx.camera.core.Logger;
|
import androidx.camera.core.Logger;
|
||||||
import androidx.camera.core.SurfaceRequest;
|
import androidx.camera.core.SurfaceRequest;
|
||||||
import androidx.camera.core.impl.MutableStateObservable;
|
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.DeactivateEncoderSurfaceBeforeStopEncoderQuirk;
|
||||||
import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
|
import androidx.camera.video.internal.compat.quirk.DeviceQuirks;
|
||||||
import androidx.camera.video.internal.compat.quirk.EncoderNotUsePersistentInputSurfaceQuirk;
|
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.AudioEncoderConfig;
|
||||||
import androidx.camera.video.internal.encoder.BufferCopiedEncodedData;
|
import androidx.camera.video.internal.encoder.BufferCopiedEncodedData;
|
||||||
import androidx.camera.video.internal.encoder.EncodeException;
|
import androidx.camera.video.internal.encoder.EncodeException;
|
||||||
@ -343,10 +346,14 @@ public final class Recorder implements VideoOutput {
|
|||||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// Members only accessed on mSequentialExecutor //
|
// Members only accessed on mSequentialExecutor //
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
private RecordingRecord mInProgressRecording = null;
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
|
RecordingRecord mInProgressRecording = null;
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
boolean mInProgressRecordingStopping = false;
|
boolean mInProgressRecordingStopping = false;
|
||||||
private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null;
|
@Nullable
|
||||||
|
private SurfaceRequest.TransformationInfo mInProgressTransformationInfo = null;
|
||||||
|
@Nullable
|
||||||
|
private SurfaceRequest.TransformationInfo mSourceTransformationInfo = null;
|
||||||
private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null;
|
private VideoValidatedEncoderProfilesProxy mResolvedEncoderProfiles = null;
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
final List<ListenableFuture<Void>> mEncodingFutures = new ArrayList<>();
|
final List<ListenableFuture<Void>> mEncodingFutures = new ArrayList<>();
|
||||||
@ -427,13 +434,15 @@ public final class Recorder implements VideoOutput {
|
|||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
ScheduledFuture<?> mSourceNonStreamingTimeout = null;
|
ScheduledFuture<?> mSourceNonStreamingTimeout = null;
|
||||||
// The Recorder has to be reset first before being configured again.
|
// The Recorder has to be reset first before being configured again.
|
||||||
private boolean mNeedsReset = false;
|
private boolean mNeedsResetBeforeNextStart = false;
|
||||||
@NonNull
|
@NonNull
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
VideoEncoderSession mVideoEncoderSession;
|
VideoEncoderSession mVideoEncoderSession;
|
||||||
@Nullable
|
@Nullable
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
VideoEncoderSession mVideoEncoderSessionToRelease = null;
|
VideoEncoderSession mVideoEncoderSessionToRelease = null;
|
||||||
|
double mAudioAmplitude = 0;
|
||||||
|
private boolean mShouldSendResumeEvent = false;
|
||||||
//--------------------------------------------------------------------------------------------//
|
//--------------------------------------------------------------------------------------------//
|
||||||
|
|
||||||
Recorder(@Nullable Executor executor, @NonNull MediaSpec mediaSpec,
|
Recorder(@Nullable Executor executor, @NonNull MediaSpec mediaSpec,
|
||||||
@ -490,6 +499,13 @@ public final class Recorder implements VideoOutput {
|
|||||||
mSequentialExecutor.execute(() -> onSourceStateChangedInternal(newState));
|
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}.
|
* 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;
|
RecordingRecord pendingRecordingToFinalize = null;
|
||||||
synchronized (mLock) {
|
synchronized (mLock) {
|
||||||
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
|
if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
|
||||||
@ -891,7 +908,7 @@ public final class Recorder implements VideoOutput {
|
|||||||
long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
|
long explicitlyStopTimeUs = TimeUnit.NANOSECONDS.toMicros(System.nanoTime());
|
||||||
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
|
RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
|
||||||
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
|
mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
|
||||||
explicitlyStopTimeUs, ERROR_NONE, null));
|
explicitlyStopTimeUs, error, errorCause));
|
||||||
break;
|
break;
|
||||||
case ERROR:
|
case ERROR:
|
||||||
// In an error state, the recording will already be finalized. Treat as a
|
// 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 (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,
|
finalizePendingRecording(pendingRecordingToFinalize, ERROR_NO_VALID_DATA,
|
||||||
new RuntimeException("Recording was stopped before any data could be "
|
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(),
|
recordingToFinalize.getOutputOptions(),
|
||||||
RecordingStats.of(/*duration=*/0L,
|
RecordingStats.of(/*duration=*/0L,
|
||||||
/*bytes=*/0L,
|
/*bytes=*/0L,
|
||||||
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause)),
|
AudioStats.of(AudioStats.AUDIO_STATE_DISABLED, mAudioErrorCause,
|
||||||
|
AUDIO_AMPLITUDE_NONE)),
|
||||||
OutputResults.of(Uri.EMPTY),
|
OutputResults.of(Uri.EMPTY),
|
||||||
error,
|
error,
|
||||||
cause));
|
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.
|
// 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
|
// Otherwise, we'll wait for the active surface's surface request listener to
|
||||||
// reset the encoder.
|
// reset the encoder.
|
||||||
requestReset(ERROR_SOURCE_INACTIVE, null);
|
requestReset(ERROR_SOURCE_INACTIVE, null, false);
|
||||||
} else {
|
} else {
|
||||||
// The source becomes inactive, the incoming new surface request has to be cached
|
// 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
|
// and be serviced after the Recorder is reset when receiving the previous
|
||||||
// surface request complete callback.
|
// surface request complete callback.
|
||||||
mNeedsReset = true;
|
mNeedsResetBeforeNextStart = true;
|
||||||
if (mInProgressRecording != null) {
|
if (mInProgressRecording != null && !mInProgressRecording.isPersistent()) {
|
||||||
// Stop any in progress recording with "source inactive" error
|
// Stop the in progress recording with "source inactive" error if it's not a
|
||||||
|
// persistent recording.
|
||||||
onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE,
|
onInProgressRecordingInternalError(mInProgressRecording, ERROR_SOURCE_INACTIVE,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
@ -995,7 +1018,8 @@ public final class Recorder implements VideoOutput {
|
|||||||
* the surface request complete callback first.
|
* the surface request complete callback first.
|
||||||
*/
|
*/
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause) {
|
void requestReset(@VideoRecordError int errorCode, @Nullable Throwable errorCause,
|
||||||
|
boolean videoOnly) {
|
||||||
boolean shouldReset = false;
|
boolean shouldReset = false;
|
||||||
boolean shouldStop = false;
|
boolean shouldStop = false;
|
||||||
synchronized (mLock) {
|
synchronized (mLock) {
|
||||||
@ -1017,14 +1041,22 @@ public final class Recorder implements VideoOutput {
|
|||||||
case PAUSED:
|
case PAUSED:
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case RECORDING:
|
case RECORDING:
|
||||||
|
Preconditions.checkState(mInProgressRecording != null, "In-progress recording"
|
||||||
|
+ " shouldn't be null when in state " + mState);
|
||||||
if (mActiveRecordingRecord != mInProgressRecording) {
|
if (mActiveRecordingRecord != mInProgressRecording) {
|
||||||
throw new AssertionError("In-progress recording does not match the active"
|
throw new AssertionError("In-progress recording does not match the active"
|
||||||
+ " recording. Unable to reset encoder.");
|
+ " recording. Unable to reset encoder.");
|
||||||
}
|
}
|
||||||
// If there's an active recording, stop it first then release the resources
|
// If there's an active persistent recording, reset the Recorder directly.
|
||||||
// at onRecordingFinalized().
|
// Otherwise, stop the recording first then release the Recorder at
|
||||||
shouldStop = true;
|
// onRecordingFinalized().
|
||||||
// Fall-through
|
if (isPersistentRecordingInProgress()) {
|
||||||
|
shouldReset = true;
|
||||||
|
} else {
|
||||||
|
shouldStop = true;
|
||||||
|
setState(State.RESETTING);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case STOPPING:
|
case STOPPING:
|
||||||
// Already stopping. Set state to RESETTING so resources will be released once
|
// Already stopping. Set state to RESETTING so resources will be released once
|
||||||
// onRecordingFinalized() runs.
|
// 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
|
// 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.
|
// the sequential executor and the state changes above are correctly handled.
|
||||||
if (shouldReset) {
|
if (shouldReset) {
|
||||||
reset();
|
if (videoOnly) {
|
||||||
|
resetVideo();
|
||||||
|
} else {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
} else if (shouldStop) {
|
} else if (shouldStop) {
|
||||||
stopInternal(mInProgressRecording, Encoder.NO_TIMESTAMP, errorCode, errorCause);
|
stopInternal(mInProgressRecording, Encoder.NO_TIMESTAMP, errorCode, errorCause);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
|
|
||||||
private void configureInternal(@NonNull SurfaceRequest surfaceRequest,
|
private void configureInternal(@NonNull SurfaceRequest surfaceRequest,
|
||||||
@NonNull Timebase videoSourceTimebase) {
|
@NonNull Timebase videoSourceTimebase) {
|
||||||
if (surfaceRequest.isServiced()) {
|
if (surfaceRequest.isServiced()) {
|
||||||
@ -1054,16 +1089,19 @@ public final class Recorder implements VideoOutput {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
|
surfaceRequest.setTransformationInfoListener(mSequentialExecutor,
|
||||||
(transformationInfo) -> mSurfaceTransformationInfo = transformationInfo);
|
(transformationInfo) -> mSourceTransformationInfo = transformationInfo);
|
||||||
Size surfaceSize = surfaceRequest.getResolution();
|
Size surfaceSize = surfaceRequest.getResolution();
|
||||||
// Fetch and cache nearest encoder profiles, if one exists.
|
// Fetch and cache nearest encoder profiles, if one exists.
|
||||||
LegacyVideoCapabilities capabilities =
|
DynamicRange dynamicRange = surfaceRequest.getDynamicRange();
|
||||||
LegacyVideoCapabilities.from(surfaceRequest.getCamera().getCameraInfo());
|
VideoCapabilities capabilities = getVideoCapabilities(
|
||||||
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize);
|
surfaceRequest.getCamera().getCameraInfo());
|
||||||
|
Quality highestSupportedQuality = capabilities.findHighestSupportedQualityFor(surfaceSize,
|
||||||
|
dynamicRange);
|
||||||
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
|
Logger.d(TAG, "Using supported quality of " + highestSupportedQuality
|
||||||
+ " for surface size " + surfaceSize);
|
+ " for surface size " + surfaceSize);
|
||||||
if (highestSupportedQuality != Quality.NONE) {
|
if (highestSupportedQuality != Quality.NONE) {
|
||||||
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality);
|
mResolvedEncoderProfiles = capabilities.getProfiles(highestSupportedQuality,
|
||||||
|
dynamicRange);
|
||||||
if (mResolvedEncoderProfiles == null) {
|
if (mResolvedEncoderProfiles == null) {
|
||||||
throw new AssertionError("Camera advertised available quality but did not "
|
throw new AssertionError("Camera advertised available quality but did not "
|
||||||
+ "produce EncoderProfiles for advertised quality.");
|
+ "produce EncoderProfiles for advertised quality.");
|
||||||
@ -1076,9 +1114,14 @@ public final class Recorder implements VideoOutput {
|
|||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
private void setupVideo(@NonNull SurfaceRequest request, @NonNull Timebase timebase) {
|
private void setupVideo(@NonNull SurfaceRequest request, @NonNull Timebase timebase) {
|
||||||
safeToCloseVideoEncoder().addListener(() -> {
|
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: "
|
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;
|
return;
|
||||||
}
|
}
|
||||||
VideoEncoderSession videoEncoderSession =
|
VideoEncoderSession videoEncoderSession =
|
||||||
@ -1110,6 +1153,12 @@ public final class Recorder implements VideoOutput {
|
|||||||
}, mSequentialExecutor);
|
}, mSequentialExecutor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
|
@ExecutedBy("mSequentialExecutor")
|
||||||
|
boolean isPersistentRecordingInProgress() {
|
||||||
|
return mInProgressRecording != null && mInProgressRecording.isPersistent();
|
||||||
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
private ListenableFuture<Void> safeToCloseVideoEncoder() {
|
private ListenableFuture<Void> safeToCloseVideoEncoder() {
|
||||||
@ -1145,7 +1194,9 @@ public final class Recorder implements VideoOutput {
|
|||||||
|
|
||||||
mVideoEncoderSessionToRelease = videoEncoderSession;
|
mVideoEncoderSessionToRelease = videoEncoderSession;
|
||||||
setLatestSurface(null);
|
setLatestSurface(null);
|
||||||
requestReset(ERROR_SOURCE_INACTIVE, null);
|
// Only reset video if the in-progress recording is persistent.
|
||||||
|
requestReset(ERROR_SOURCE_INACTIVE, null,
|
||||||
|
isPersistentRecordingInProgress());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -1160,17 +1211,14 @@ public final class Recorder implements VideoOutput {
|
|||||||
void onConfigured() {
|
void onConfigured() {
|
||||||
RecordingRecord recordingToStart = null;
|
RecordingRecord recordingToStart = null;
|
||||||
RecordingRecord pendingRecordingToFinalize = null;
|
RecordingRecord pendingRecordingToFinalize = null;
|
||||||
|
boolean continuePersistentRecording = false;
|
||||||
@VideoRecordError int error = ERROR_NONE;
|
@VideoRecordError int error = ERROR_NONE;
|
||||||
Throwable errorCause = null;
|
Throwable errorCause = null;
|
||||||
boolean startRecordingPaused = false;
|
boolean recordingPaused = false;
|
||||||
synchronized (mLock) {
|
synchronized (mLock) {
|
||||||
switch (mState) {
|
switch (mState) {
|
||||||
case IDLING:
|
case IDLING:
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case RECORDING:
|
|
||||||
// Fall-through
|
|
||||||
case PAUSED:
|
|
||||||
// Fall-through
|
|
||||||
case RESETTING:
|
case RESETTING:
|
||||||
throw new AssertionError(
|
throw new AssertionError(
|
||||||
"Incorrectly invoke onConfigured() in state " + mState);
|
"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.");
|
+ "STOPPING state when it's not waiting for a new surface.");
|
||||||
}
|
}
|
||||||
break;
|
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:
|
case CONFIGURING:
|
||||||
setState(State.IDLING);
|
setState(State.IDLING);
|
||||||
break;
|
break;
|
||||||
@ -1188,7 +1245,7 @@ public final class Recorder implements VideoOutput {
|
|||||||
"onConfigured() was invoked when the Recorder had encountered error");
|
"onConfigured() was invoked when the Recorder had encountered error");
|
||||||
break;
|
break;
|
||||||
case PENDING_PAUSED:
|
case PENDING_PAUSED:
|
||||||
startRecordingPaused = true;
|
recordingPaused = true;
|
||||||
// Fall through
|
// Fall through
|
||||||
case PENDING_RECORDING:
|
case PENDING_RECORDING:
|
||||||
if (mActiveRecordingRecord != null) {
|
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).
|
// Start new active recording inline on sequential executor (but unlocked).
|
||||||
startRecording(recordingToStart, startRecordingPaused);
|
startRecording(recordingToStart, recordingPaused);
|
||||||
} else if (pendingRecordingToFinalize != null) {
|
} else if (pendingRecordingToFinalize != null) {
|
||||||
finalizePendingRecording(pendingRecordingToFinalize, error, errorCause);
|
finalizePendingRecording(pendingRecordingToFinalize, error, errorCause);
|
||||||
}
|
}
|
||||||
@ -1252,7 +1321,7 @@ public final class Recorder implements VideoOutput {
|
|||||||
throws AudioSourceAccessException, InvalidConfigException {
|
throws AudioSourceAccessException, InvalidConfigException {
|
||||||
MediaSpec mediaSpec = getObservableData(mMediaSpec);
|
MediaSpec mediaSpec = getObservableData(mMediaSpec);
|
||||||
// Resolve the audio mime info
|
// Resolve the audio mime info
|
||||||
MimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
|
AudioMimeInfo audioMimeInfo = resolveAudioMimeInfo(mediaSpec, mResolvedEncoderProfiles);
|
||||||
Timebase audioSourceTimebase = Timebase.UPTIME;
|
Timebase audioSourceTimebase = Timebase.UPTIME;
|
||||||
|
|
||||||
// Select and create the audio source
|
// Select and create the audio source
|
||||||
@ -1403,8 +1472,10 @@ public final class Recorder implements VideoOutput {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mSurfaceTransformationInfo != null) {
|
SurfaceRequest.TransformationInfo transformationInfo = mSourceTransformationInfo;
|
||||||
mediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees());
|
if (transformationInfo != null) {
|
||||||
|
setInProgressTransformationInfo(transformationInfo);
|
||||||
|
mediaMuxer.setOrientationHint(transformationInfo.getRotationDegrees());
|
||||||
}
|
}
|
||||||
Location location = recordingToStart.getOutputOptions().getLocation();
|
Location location = recordingToStart.getOutputOptions().getLocation();
|
||||||
if (location != null) {
|
if (location != null) {
|
||||||
@ -1507,7 +1578,9 @@ public final class Recorder implements VideoOutput {
|
|||||||
"The Recorder doesn't support recording with audio");
|
"The Recorder doesn't support recording with audio");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setupAudio(recordingToStart);
|
if (!mInProgressRecording.isPersistent() || mAudioEncoder == null) {
|
||||||
|
setupAudio(recordingToStart);
|
||||||
|
}
|
||||||
setAudioState(AudioState.ENABLED);
|
setAudioState(AudioState.ENABLED);
|
||||||
} catch (AudioSourceAccessException | InvalidConfigException e) {
|
} catch (AudioSourceAccessException | InvalidConfigException e) {
|
||||||
Logger.e(TAG, "Unable to create audio resource with error: ", e);
|
Logger.e(TAG, "Unable to create audio resource with error: ", e);
|
||||||
@ -1524,7 +1597,7 @@ public final class Recorder implements VideoOutput {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
initEncoderAndAudioSourceCallbacks(recordingToStart);
|
updateEncoderCallbacks(recordingToStart, false);
|
||||||
if (isAudioEnabled()) {
|
if (isAudioEnabled()) {
|
||||||
mAudioSource.start(recordingToStart.isMuted());
|
mAudioSource.start(recordingToStart.isMuted());
|
||||||
mAudioEncoder.start();
|
mAudioEncoder.start();
|
||||||
@ -1537,7 +1610,17 @@ public final class Recorder implements VideoOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@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(
|
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
||||||
completer -> {
|
completer -> {
|
||||||
mVideoEncoder.setEncoderCallback(new EncoderCallback() {
|
mVideoEncoder.setEncoderCallback(new EncoderCallback() {
|
||||||
@ -1633,7 +1716,7 @@ public final class Recorder implements VideoOutput {
|
|||||||
return "videoEncodingFuture";
|
return "videoEncodingFuture";
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (isAudioEnabled()) {
|
if (isAudioEnabled() && !videoOnly) {
|
||||||
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
mEncodingFutures.add(CallbackToFutureAdapter.getFuture(
|
||||||
completer -> {
|
completer -> {
|
||||||
Consumer<Throwable> audioErrorConsumer = throwable -> {
|
Consumer<Throwable> audioErrorConsumer = throwable -> {
|
||||||
@ -1673,6 +1756,11 @@ public final class Recorder implements VideoOutput {
|
|||||||
audioErrorConsumer.accept(throwable);
|
audioErrorConsumer.accept(throwable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAmplitudeValue(double maxAmplitude) {
|
||||||
|
mAudioAmplitude = maxAmplitude;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
mAudioEncoder.setEncoderCallback(new EncoderCallback() {
|
mAudioEncoder.setEncoderCallback(new EncoderCallback() {
|
||||||
@ -1759,12 +1847,16 @@ public final class Recorder implements VideoOutput {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onFailure(@NonNull Throwable t) {
|
public void onFailure(@NonNull Throwable t) {
|
||||||
Logger.d(TAG, "Encodings end with error: " + t);
|
Preconditions.checkState(mInProgressRecording != null,
|
||||||
// If the media muxer hasn't been set up, assume the encoding fails
|
"In-progress recording shouldn't be null");
|
||||||
// because of no valid data has been produced.
|
// If a persistent recording requires reconfiguring the video encoder,
|
||||||
finalizeInProgressRecording(
|
// the previous encoder future has to be canceled without finalizing the
|
||||||
mMediaMuxer == null ? ERROR_NO_VALID_DATA : ERROR_ENCODING_FAILED,
|
// in-progress recording.
|
||||||
t);
|
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
|
// Can use direct executor since completers are always completed on sequential
|
||||||
@ -1905,11 +1997,20 @@ public final class Recorder implements VideoOutput {
|
|||||||
if (isAudioEnabled()) {
|
if (isAudioEnabled()) {
|
||||||
mAudioEncoder.start();
|
mAudioEncoder.start();
|
||||||
}
|
}
|
||||||
mVideoEncoder.start();
|
// If a persistent recording is resumed immediately after the VideoCapture is rebound
|
||||||
|
// to a camera, it's possible that the encoder hasn't been created yet. Then the
|
||||||
mInProgressRecording.updateVideoRecordEvent(VideoRecordEvent.resume(
|
// encoder will be started once it's initialized. So only start the encoder when it's
|
||||||
mInProgressRecording.getOutputOptions(),
|
// not null.
|
||||||
getInProgressRecordingStats()));
|
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;
|
mAudioEncoder = null;
|
||||||
mAudioOutputConfig = null;
|
mAudioOutputConfig = null;
|
||||||
}
|
}
|
||||||
tryReleaseVideoEncoder();
|
|
||||||
if (mAudioSource != null) {
|
if (mAudioSource != null) {
|
||||||
releaseCurrentAudioSource();
|
releaseCurrentAudioSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAudioState(AudioState.INITIALIZING);
|
setAudioState(AudioState.INITIALIZING);
|
||||||
onReset();
|
resetVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("FutureReturnValueIgnored")
|
@SuppressWarnings("FutureReturnValueIgnored")
|
||||||
@ -2031,7 +2131,8 @@ public final class Recorder implements VideoOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
private void onReset() {
|
private void onResetVideo() {
|
||||||
|
boolean shouldConfigure = true;
|
||||||
synchronized (mLock) {
|
synchronized (mLock) {
|
||||||
switch (mState) {
|
switch (mState) {
|
||||||
case PENDING_PAUSED:
|
case PENDING_PAUSED:
|
||||||
@ -2044,6 +2145,10 @@ public final class Recorder implements VideoOutput {
|
|||||||
case PAUSED:
|
case PAUSED:
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case RECORDING:
|
case RECORDING:
|
||||||
|
if (isPersistentRecordingInProgress()) {
|
||||||
|
shouldConfigure = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case IDLING:
|
case IDLING:
|
||||||
// Fall-through
|
// 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 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);
|
configureInternal(mLatestSurfaceRequest, mVideoSourceTimebase);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExecutedBy("mSequentialExecutor")
|
||||||
|
private void resetVideo() {
|
||||||
|
if (mVideoEncoder != null) {
|
||||||
|
Logger.d(TAG, "Releasing video encoder.");
|
||||||
|
tryReleaseVideoEncoder();
|
||||||
|
}
|
||||||
|
onResetVideo();
|
||||||
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
private int internalAudioStateToAudioStatsState(@NonNull AudioState audioState) {
|
private int internalAudioStateToAudioStatsState(@NonNull AudioState audioState) {
|
||||||
switch (audioState) {
|
switch (audioState) {
|
||||||
@ -2168,7 +2283,9 @@ public final class Recorder implements VideoOutput {
|
|||||||
mRecordingStopError = ERROR_UNKNOWN;
|
mRecordingStopError = ERROR_UNKNOWN;
|
||||||
mRecordingStopErrorCause = null;
|
mRecordingStopErrorCause = null;
|
||||||
mAudioErrorCause = null;
|
mAudioErrorCause = null;
|
||||||
|
mAudioAmplitude = AUDIO_AMPLITUDE_NONE;
|
||||||
clearPendingAudioRingBuffer();
|
clearPendingAudioRingBuffer();
|
||||||
|
setInProgressTransformationInfo(null);
|
||||||
|
|
||||||
switch (mAudioState) {
|
switch (mAudioState) {
|
||||||
case IDLING:
|
case IDLING:
|
||||||
@ -2351,7 +2468,7 @@ public final class Recorder implements VideoOutput {
|
|||||||
startRecordingPaused = true;
|
startRecordingPaused = true;
|
||||||
// Fall-through
|
// Fall-through
|
||||||
case PENDING_RECORDING:
|
case PENDING_RECORDING:
|
||||||
if (mActiveRecordingRecord != null || mNeedsReset) {
|
if (mActiveRecordingRecord != null || mNeedsResetBeforeNextStart) {
|
||||||
// Active recording is still finalizing or the Recorder is expected to be
|
// Active recording is still finalizing or the Recorder is expected to be
|
||||||
// reset. Pending recording will be serviced in onRecordingFinalized() or
|
// reset. Pending recording will be serviced in onRecordingFinalized() or
|
||||||
// in onReset().
|
// in onReset().
|
||||||
@ -2466,7 +2583,8 @@ public final class Recorder implements VideoOutput {
|
|||||||
@NonNull
|
@NonNull
|
||||||
RecordingStats getInProgressRecordingStats() {
|
RecordingStats getInProgressRecordingStats() {
|
||||||
return RecordingStats.of(mRecordingDurationNs, mRecordingBytes,
|
return RecordingStats.of(mRecordingDurationNs, mRecordingBytes,
|
||||||
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause));
|
AudioStats.of(internalAudioStateToAudioStatsState(mAudioState), mAudioErrorCause,
|
||||||
|
mAudioAmplitude));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||||
@ -2520,7 +2638,7 @@ public final class Recorder implements VideoOutput {
|
|||||||
if (streamState == null) {
|
if (streamState == null) {
|
||||||
streamState = internalStateToStreamState(mState);
|
streamState = internalStateToStreamState(mState);
|
||||||
}
|
}
|
||||||
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState));
|
mStreamInfo.setState(StreamInfo.of(mStreamId, streamState, mInProgressTransformationInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExecutedBy("mSequentialExecutor")
|
@ExecutedBy("mSequentialExecutor")
|
||||||
@ -2542,7 +2660,20 @@ public final class Recorder implements VideoOutput {
|
|||||||
}
|
}
|
||||||
Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + streamId);
|
Logger.d(TAG, "Transitioning streamId: " + mStreamId + " --> " + 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) {
|
if (mNonPendingState != state) {
|
||||||
mNonPendingState = state;
|
mNonPendingState = state;
|
||||||
mStreamInfo.setState(
|
mStreamInfo.setState(StreamInfo.of(mStreamId, internalStateToStreamState(state),
|
||||||
StreamInfo.of(mStreamId, internalStateToStreamState(state)));
|
mInProgressTransformationInfo));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2614,6 +2745,21 @@ public final class Recorder implements VideoOutput {
|
|||||||
return defaultMuxerFormat;
|
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
|
@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
|
||||||
@AutoValue
|
@AutoValue
|
||||||
abstract static class RecordingRecord implements AutoCloseable {
|
abstract static class RecordingRecord implements AutoCloseable {
|
||||||
@ -2642,6 +2788,7 @@ public final class Recorder implements VideoOutput {
|
|||||||
pendingRecording.getListenerExecutor(),
|
pendingRecording.getListenerExecutor(),
|
||||||
pendingRecording.getEventListener(),
|
pendingRecording.getEventListener(),
|
||||||
pendingRecording.isAudioEnabled(),
|
pendingRecording.isAudioEnabled(),
|
||||||
|
pendingRecording.isPersistent(),
|
||||||
recordingId
|
recordingId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2657,6 +2804,8 @@ public final class Recorder implements VideoOutput {
|
|||||||
|
|
||||||
abstract boolean hasAudioEnabled();
|
abstract boolean hasAudioEnabled();
|
||||||
|
|
||||||
|
abstract boolean isPersistent();
|
||||||
|
|
||||||
abstract long getRecordingId();
|
abstract long getRecordingId();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -2720,8 +2869,13 @@ public final class Recorder implements VideoOutput {
|
|||||||
// Toggle on pending status for the video file.
|
// Toggle on pending status for the video file.
|
||||||
contentValues.put(MediaStore.Video.Media.IS_PENDING, PENDING);
|
contentValues.put(MediaStore.Video.Media.IS_PENDING, PENDING);
|
||||||
}
|
}
|
||||||
outputUri = mediaStoreOutputOptions.getContentResolver().insert(
|
try {
|
||||||
mediaStoreOutputOptions.getCollectionUri(), contentValues);
|
outputUri = mediaStoreOutputOptions.getContentResolver().insert(
|
||||||
|
mediaStoreOutputOptions.getCollectionUri(), contentValues);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new IOException("Unable to create MediaStore entry by " + e,
|
||||||
|
e);
|
||||||
|
}
|
||||||
if (outputUri == null) {
|
if (outputUri == null) {
|
||||||
throw new IOException("Unable to create MediaStore entry.");
|
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"
|
throw new AssertionError("One-time media muxer creation has already occurred for"
|
||||||
+ " recording " + this);
|
+ " 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -19,6 +19,7 @@ package androidx.camera.video;
|
|||||||
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
|
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
import androidx.annotation.RestrictTo;
|
import androidx.annotation.RestrictTo;
|
||||||
import androidx.camera.core.impl.utils.CloseGuardHelper;
|
import androidx.camera.core.impl.utils.CloseGuardHelper;
|
||||||
@ -53,13 +54,15 @@ public final class Recording implements AutoCloseable {
|
|||||||
private final Recorder mRecorder;
|
private final Recorder mRecorder;
|
||||||
private final long mRecordingId;
|
private final long mRecordingId;
|
||||||
private final OutputOptions mOutputOptions;
|
private final OutputOptions mOutputOptions;
|
||||||
|
private final boolean mIsPersistent;
|
||||||
private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
|
private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
|
||||||
|
|
||||||
Recording(@NonNull Recorder recorder, long recordingId, @NonNull OutputOptions options,
|
Recording(@NonNull Recorder recorder, long recordingId, @NonNull OutputOptions options,
|
||||||
boolean finalizedOnCreation) {
|
boolean isPersistent, boolean finalizedOnCreation) {
|
||||||
mRecorder = recorder;
|
mRecorder = recorder;
|
||||||
mRecordingId = recordingId;
|
mRecordingId = recordingId;
|
||||||
mOutputOptions = options;
|
mOutputOptions = options;
|
||||||
|
mIsPersistent = isPersistent;
|
||||||
|
|
||||||
if (finalizedOnCreation) {
|
if (finalizedOnCreation) {
|
||||||
mIsClosed.set(true);
|
mIsClosed.set(true);
|
||||||
@ -80,6 +83,7 @@ public final class Recording implements AutoCloseable {
|
|||||||
return new Recording(pendingRecording.getRecorder(),
|
return new Recording(pendingRecording.getRecorder(),
|
||||||
recordingId,
|
recordingId,
|
||||||
pendingRecording.getOutputOptions(),
|
pendingRecording.getOutputOptions(),
|
||||||
|
pendingRecording.isPersistent(),
|
||||||
/*finalizedOnCreation=*/false);
|
/*finalizedOnCreation=*/false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +104,7 @@ public final class Recording implements AutoCloseable {
|
|||||||
return new Recording(pendingRecording.getRecorder(),
|
return new Recording(pendingRecording.getRecorder(),
|
||||||
recordingId,
|
recordingId,
|
||||||
pendingRecording.getOutputOptions(),
|
pendingRecording.getOutputOptions(),
|
||||||
|
pendingRecording.isPersistent(),
|
||||||
/*finalizedOnCreation=*/true);
|
/*finalizedOnCreation=*/true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +113,20 @@ public final class Recording implements AutoCloseable {
|
|||||||
return mOutputOptions;
|
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.
|
* Pauses the current recording if active.
|
||||||
*
|
*
|
||||||
@ -193,11 +212,7 @@ public final class Recording implements AutoCloseable {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void close() {
|
public void close() {
|
||||||
mCloseGuard.close();
|
stopWithError(VideoRecordEvent.Finalize.ERROR_NONE, /*errorCause=*/ null);
|
||||||
if (mIsClosed.getAndSet(true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mRecorder.stop(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -205,7 +220,8 @@ public final class Recording implements AutoCloseable {
|
|||||||
protected void finalize() throws Throwable {
|
protected void finalize() throws Throwable {
|
||||||
try {
|
try {
|
||||||
mCloseGuard.warnIfOpen();
|
mCloseGuard.warnIfOpen();
|
||||||
stop();
|
stopWithError(VideoRecordEvent.Finalize.ERROR_RECORDING_GARBAGE_COLLECTED,
|
||||||
|
new RuntimeException("Recording stopped due to being garbage collected."));
|
||||||
} finally {
|
} finally {
|
||||||
super.finalize();
|
super.finalize();
|
||||||
}
|
}
|
||||||
@ -231,5 +247,14 @@ public final class Recording implements AutoCloseable {
|
|||||||
public boolean isClosed() {
|
public boolean isClosed() {
|
||||||
return mIsClosed.get();
|
return mIsClosed.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void stopWithError(@VideoRecordEvent.Finalize.VideoRecordError int error,
|
||||||
|
@Nullable Throwable errorCause) {
|
||||||
|
mCloseGuard.close();
|
||||||
|
if (mIsClosed.getAndSet(true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mRecorder.stop(this, error, errorCause);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
for i in "PendingRecording" "Recording" "Recorder"; do
|
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 ..
|
diff3 -m ../Suckless$i.java base/$i.java new/$i.java > Suckless$i.java && mv Suckless$i.java ..
|
||||||
done
|
done
|
||||||
|
@ -4,6 +4,7 @@ import android.content.SharedPreferences
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
|
||||||
open class BaseActivity: AppCompatActivity() {
|
open class BaseActivity: AppCompatActivity() {
|
||||||
protected lateinit var sharedPrefs: SharedPreferences
|
protected lateinit var sharedPrefs: SharedPreferences
|
||||||
@ -12,7 +13,7 @@ open class BaseActivity: AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
sharedPrefs = (application as VolumeManagerApp).sharedPreferences
|
sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
theme = Theme.fromSharedPrefs(sharedPrefs)
|
theme = Theme.fromSharedPrefs(sharedPrefs)
|
||||||
if (applyCustomTheme) {
|
if (applyCustomTheme) {
|
||||||
setTheme(theme.toResourceId())
|
setTheme(theme.toResourceId())
|
||||||
|
@ -7,7 +7,10 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import android.view.*
|
import android.view.MotionEvent
|
||||||
|
import android.view.ScaleGestureDetector
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.View
|
||||||
import android.view.animation.Animation
|
import android.view.animation.Animation
|
||||||
import android.view.animation.LinearInterpolator
|
import android.view.animation.LinearInterpolator
|
||||||
import android.view.animation.RotateAnimation
|
import android.view.animation.RotateAnimation
|
||||||
@ -18,6 +21,7 @@ import androidx.annotation.RequiresApi
|
|||||||
import androidx.camera.core.AspectRatio
|
import androidx.camera.core.AspectRatio
|
||||||
import androidx.camera.core.Camera
|
import androidx.camera.core.Camera
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.DynamicRange
|
||||||
import androidx.camera.core.FocusMeteringAction
|
import androidx.camera.core.FocusMeteringAction
|
||||||
import androidx.camera.core.ImageCapture
|
import androidx.camera.core.ImageCapture
|
||||||
import androidx.camera.core.ImageCaptureException
|
import androidx.camera.core.ImageCaptureException
|
||||||
@ -41,8 +45,8 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import sushi.hardcore.droidfs.databinding.ActivityCameraBinding
|
import sushi.hardcore.droidfs.databinding.ActivityCameraBinding
|
||||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
import sushi.hardcore.droidfs.util.IntentUtils
|
|
||||||
import sushi.hardcore.droidfs.util.PathUtils
|
import sushi.hardcore.droidfs.util.PathUtils
|
||||||
|
import sushi.hardcore.droidfs.util.finishOnClose
|
||||||
import sushi.hardcore.droidfs.video_recording.AsynchronousSeekableWriter
|
import sushi.hardcore.droidfs.video_recording.AsynchronousSeekableWriter
|
||||||
import sushi.hardcore.droidfs.video_recording.FFmpegMuxer
|
import sushi.hardcore.droidfs.video_recording.FFmpegMuxer
|
||||||
import sushi.hardcore.droidfs.video_recording.SeekableWriter
|
import sushi.hardcore.droidfs.video_recording.SeekableWriter
|
||||||
@ -51,7 +55,9 @@ import sushi.hardcore.droidfs.widgets.EditTextDialog
|
|||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.Random
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
@ -112,7 +118,10 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
|||||||
binding = ActivityCameraBinding.inflate(layoutInflater)
|
binding = ActivityCameraBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
supportActionBar?.hide()
|
supportActionBar?.hide()
|
||||||
encryptedVolume = IntentUtils.getParcelableExtra(intent, "volume")!!
|
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
|
||||||
|
intent.getIntExtra("volumeId", -1)
|
||||||
|
)!!
|
||||||
|
finishOnClose(encryptedVolume)
|
||||||
outputDirectory = intent.getStringExtra("path")!!
|
outputDirectory = intent.getStringExtra("path")!!
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
@ -414,7 +423,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
|||||||
refreshVideoCapture()
|
refreshVideoCapture()
|
||||||
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture)
|
camera = cameraProvider.bindToLifecycle(this, cameraSelector, cameraPreview, videoCapture)
|
||||||
if (qualities == null) {
|
if (qualities == null) {
|
||||||
qualities = QualitySelector.getSupportedQualities(camera!!.cameraInfo)
|
qualities = SucklessRecorder.getVideoCapabilities(camera!!.cameraInfo).getSupportedQualities(DynamicRange.UNSPECIFIED)
|
||||||
}
|
}
|
||||||
videoCapture
|
videoCapture
|
||||||
} else {
|
} else {
|
||||||
@ -576,11 +585,7 @@ class CameraActivity : BaseActivity(), SensorOrientationListener.Listener {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (encryptedVolume.isClosed()) {
|
sensorOrientationListener.addListener(this)
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
sensorOrientationListener.addListener(this)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOrientationChange(newOrientation: Int) {
|
override fun onOrientationChange(newOrientation: Int) {
|
||||||
|
@ -16,7 +16,7 @@ import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
|||||||
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
||||||
import sushi.hardcore.droidfs.util.IntentUtils
|
import sushi.hardcore.droidfs.util.IntentUtils
|
||||||
import sushi.hardcore.droidfs.util.ObjRef
|
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 sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@ -89,8 +89,8 @@ class ChangePasswordActivity: BaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun changeVolumePassword() {
|
private fun changeVolumePassword() {
|
||||||
val newPassword = WidgetUtil.encodeEditTextContent(binding.editNewPassword)
|
val newPassword = UIUtils.encodeEditTextContent(binding.editNewPassword)
|
||||||
val newPasswordConfirm = WidgetUtil.encodeEditTextContent(binding.editPasswordConfirm)
|
val newPasswordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
|
||||||
@SuppressLint("NewApi")
|
@SuppressLint("NewApi")
|
||||||
if (!newPassword.contentEquals(newPasswordConfirm)) {
|
if (!newPassword.contentEquals(newPasswordConfirm)) {
|
||||||
Toast.makeText(this, R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
|
||||||
@ -135,7 +135,7 @@ class ChangePasswordActivity: BaseActivity() {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
val currentPassword = if (givenHash == null) {
|
val currentPassword = if (givenHash == null) {
|
||||||
WidgetUtil.encodeEditTextContent(binding.editCurrentPassword)
|
UIUtils.encodeEditTextContent(binding.editCurrentPassword)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@ -160,7 +160,7 @@ class ChangePasswordActivity: BaseActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (success) {
|
if (success) {
|
||||||
if (volumeDatabase.isHashSaved(volume.name)) {
|
if (volumeDatabase.isHashSaved(volume)) {
|
||||||
volumeDatabase.removeHash(volume)
|
volumeDatabase.removeHash(volume)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
20
app/src/main/java/sushi/hardcore/droidfs/ClosingService.kt
Normal file
20
app/src/main/java/sushi/hardcore/droidfs/ClosingService.kt
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package sushi.hardcore.droidfs
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dummy background service listening for application task removal in order to
|
||||||
|
* close all volumes still open on quit.
|
||||||
|
*
|
||||||
|
* Should only be running when usfBackground is enabled AND usfKeepOpen is disabled.
|
||||||
|
*/
|
||||||
|
class ClosingService : Service() {
|
||||||
|
override fun onBind(intent: Intent) = null
|
||||||
|
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent) {
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
(application as VolumeManagerApp).volumeManager.closeAll()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,256 @@
|
|||||||
|
package sushi.hardcore.droidfs
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.system.Os
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
|
import sushi.hardcore.droidfs.util.Compat
|
||||||
|
import sushi.hardcore.droidfs.util.Wiper
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class EncryptedFileProvider(context: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "EncryptedFileProvider"
|
||||||
|
fun getTmpFilesDir(context: Context) = File(context.cacheDir, "tmp")
|
||||||
|
|
||||||
|
var exportMethod = ExportMethod.AUTO
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ExportMethod {
|
||||||
|
AUTO,
|
||||||
|
DISK,
|
||||||
|
MEMORY;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parse(value: String) = when (value) {
|
||||||
|
"auto" -> EncryptedFileProvider.ExportMethod.AUTO
|
||||||
|
"disk" -> EncryptedFileProvider.ExportMethod.DISK
|
||||||
|
"memory" -> EncryptedFileProvider.ExportMethod.MEMORY
|
||||||
|
else -> throw IllegalArgumentException("Invalid export method: $value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val memoryInfo = ActivityManager.MemoryInfo()
|
||||||
|
private val isMemFileSupported = Compat.isMemFileSupported()
|
||||||
|
private val tmpFilesDir by lazy { getTmpFilesDir(context) }
|
||||||
|
private val handler by lazy { Handler(context.mainLooper) }
|
||||||
|
|
||||||
|
init {
|
||||||
|
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(
|
||||||
|
memoryInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getString("export_method", null)?.let {
|
||||||
|
exportMethod = ExportMethod.parse(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExportedDiskFile private constructor(
|
||||||
|
path: String,
|
||||||
|
private val file: File,
|
||||||
|
private val handler: Handler
|
||||||
|
) : ExportedFile(path) {
|
||||||
|
companion object {
|
||||||
|
fun create(path: String, tmpFilesDir: File, handler: Handler): ExportedDiskFile? {
|
||||||
|
val uuid = UUID.randomUUID().toString()
|
||||||
|
val file = File(tmpFilesDir, uuid)
|
||||||
|
return if (file.createNewFile()) {
|
||||||
|
ExportedDiskFile(path, file, handler)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor {
|
||||||
|
return if (furtive) {
|
||||||
|
ParcelFileDescriptor.open(file, mode, handler) {
|
||||||
|
free()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ParcelFileDescriptor.open(file, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun free() {
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
Wiper.wipe(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExportedMemFile private constructor(path: String, private val file: MemFile) :
|
||||||
|
ExportedFile(path) {
|
||||||
|
companion object {
|
||||||
|
fun create(path: String, size: Long): ExportedMemFile? {
|
||||||
|
val uuid = UUID.randomUUID().toString()
|
||||||
|
MemFile.create(uuid, size)?.let {
|
||||||
|
return ExportedMemFile(path, it)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor {
|
||||||
|
val fd = if (furtive) {
|
||||||
|
file.toParcelFileDescriptor()
|
||||||
|
} else {
|
||||||
|
file.dup()
|
||||||
|
}
|
||||||
|
if (mode and ParcelFileDescriptor.MODE_TRUNCATE != 0) {
|
||||||
|
Os.ftruncate(fd.fileDescriptor, 0)
|
||||||
|
} else {
|
||||||
|
FileInputStream(fd.fileDescriptor).apply {
|
||||||
|
channel.position(0)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fd
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun free() = file.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class ExportedFile(val path: String) {
|
||||||
|
var isValid = true
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun invalidate() {
|
||||||
|
isValid = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param furtive If set to true, the file will be deleted when closed
|
||||||
|
*/
|
||||||
|
abstract fun open(mode: Int, furtive: Boolean): ParcelFileDescriptor
|
||||||
|
abstract fun free()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createFile(
|
||||||
|
path: String,
|
||||||
|
size: Long,
|
||||||
|
): ExportedFile? {
|
||||||
|
val diskFile by lazy { ExportedDiskFile.create(path, tmpFilesDir, handler) }
|
||||||
|
val memFile by lazy { ExportedMemFile.create(path, size) }
|
||||||
|
return when (exportMethod) {
|
||||||
|
ExportMethod.MEMORY -> memFile
|
||||||
|
ExportMethod.DISK -> diskFile
|
||||||
|
ExportMethod.AUTO -> {
|
||||||
|
if (isMemFileSupported && size < memoryInfo.availMem * 0.8) {
|
||||||
|
memFile
|
||||||
|
} else {
|
||||||
|
diskFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportFile(
|
||||||
|
exportedFile: ExportedFile,
|
||||||
|
encryptedVolume: EncryptedVolume,
|
||||||
|
): Boolean {
|
||||||
|
val pfd = exportedFile.open(ParcelFileDescriptor.MODE_WRITE_ONLY, false)
|
||||||
|
return encryptedVolume.exportFile(exportedFile.path, FileOutputStream(pfd.fileDescriptor)).also {
|
||||||
|
pfd.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Error {
|
||||||
|
SUCCESS,
|
||||||
|
INVALID_STATE,
|
||||||
|
WRITE_ACCESS_DENIED,
|
||||||
|
UNSUPPORTED_APPEND,
|
||||||
|
UNSUPPORTED_RW,
|
||||||
|
;
|
||||||
|
|
||||||
|
fun log() {
|
||||||
|
Log.e(
|
||||||
|
TAG, when (this) {
|
||||||
|
SUCCESS -> "No error"
|
||||||
|
INVALID_STATE -> "Read after write is not supported"
|
||||||
|
WRITE_ACCESS_DENIED -> "Write access unauthorized"
|
||||||
|
UNSUPPORTED_APPEND -> "Appending is not supported"
|
||||||
|
UNSUPPORTED_RW -> "Read-write access requires Android 11 or later"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param furtive If set to true, the file will be deleted when closed
|
||||||
|
*/
|
||||||
|
fun openFile(
|
||||||
|
file: ExportedFile,
|
||||||
|
mode: String,
|
||||||
|
encryptedVolume: EncryptedVolume,
|
||||||
|
volumeScope: CoroutineScope,
|
||||||
|
furtive: Boolean,
|
||||||
|
allowWrites: Boolean,
|
||||||
|
): Pair<ParcelFileDescriptor?, Error> {
|
||||||
|
val mode = ParcelFileDescriptor.parseMode(mode)
|
||||||
|
return if (mode and ParcelFileDescriptor.MODE_READ_ONLY != 0) {
|
||||||
|
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
|
||||||
|
Pair(file.open(mode, furtive), Error.SUCCESS)
|
||||||
|
} else {
|
||||||
|
if (!allowWrites) {
|
||||||
|
return Pair(null, Error.WRITE_ACCESS_DENIED)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun import(input: InputStream): Boolean {
|
||||||
|
return if (encryptedVolume.importFile(input, file.path)) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to import file")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode and ParcelFileDescriptor.MODE_WRITE_ONLY != 0) {
|
||||||
|
if (mode and ParcelFileDescriptor.MODE_APPEND != 0) {
|
||||||
|
return Pair(null, Error.UNSUPPORTED_APPEND)
|
||||||
|
}
|
||||||
|
if (mode and ParcelFileDescriptor.MODE_TRUNCATE == 0) {
|
||||||
|
Log.w(TAG, "Truncating file despite not being requested")
|
||||||
|
}
|
||||||
|
val pipe = ParcelFileDescriptor.createReliablePipe()
|
||||||
|
val input = FileInputStream(pipe[0].fileDescriptor)
|
||||||
|
volumeScope.launch {
|
||||||
|
if (import(input)) {
|
||||||
|
file.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Pair(pipe[1], Error.SUCCESS)
|
||||||
|
} else { // read-write
|
||||||
|
if (!file.isValid) return Pair(null, Error.INVALID_STATE)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val fd = file.open(mode, false)
|
||||||
|
Pair(ParcelFileDescriptor.wrap(fd, handler) { e ->
|
||||||
|
if (e == null) {
|
||||||
|
import(FileInputStream(fd.fileDescriptor))
|
||||||
|
if (furtive) {
|
||||||
|
file.free()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, Error.SUCCESS)
|
||||||
|
} else {
|
||||||
|
Pair(null, Error.UNSUPPORTED_RW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -4,66 +4,48 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import sushi.hardcore.droidfs.content_providers.DiskFileProvider
|
import androidx.preference.PreferenceManager
|
||||||
import sushi.hardcore.droidfs.content_providers.MemoryFileProvider
|
|
||||||
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
|
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
|
||||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
|
||||||
import sushi.hardcore.droidfs.util.Version
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class FileShare(private val encryptedVolume: EncryptedVolume, private val context: Context) {
|
class FileShare(context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val content_type_all = "*/*"
|
private const val CONTENT_TYPE_ANY = "*/*"
|
||||||
fun getContentType(filename: String, previousContentType: String?): String {
|
private fun getContentType(filename: String, previousContentType: String?): String {
|
||||||
if (content_type_all != previousContentType) {
|
if (CONTENT_TYPE_ANY != previousContentType) {
|
||||||
var contentType = MimeTypeMap.getSingleton()
|
var contentType = MimeTypeMap.getSingleton()
|
||||||
.getMimeTypeFromExtension(File(filename).extension)
|
.getMimeTypeFromExtension(File(filename).extension)
|
||||||
if (contentType == null) {
|
if (contentType == null) {
|
||||||
contentType = content_type_all
|
contentType = CONTENT_TYPE_ANY
|
||||||
}
|
}
|
||||||
if (previousContentType == null) {
|
if (previousContentType == null) {
|
||||||
return contentType
|
return contentType
|
||||||
} else if (previousContentType != contentType) {
|
} else if (previousContentType != contentType) {
|
||||||
return content_type_all
|
return CONTENT_TYPE_ANY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return previousContentType
|
return previousContentType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val fileProvider: TemporaryFileProvider<*>
|
private val usfSafWrite = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("usf_saf_write", false)
|
||||||
|
|
||||||
init {
|
private fun exportFile(exportedFile: EncryptedFileProvider.ExportedFile, size: Long, volumeId: Int, previousContentType: String? = null): Pair<Uri, String>? {
|
||||||
var provider: MemoryFileProvider? = null
|
val uri = TemporaryFileProvider.instance.exportFile(exportedFile, size, volumeId) ?: return null
|
||||||
System.getProperty("os.version")?.let {
|
return Pair(uri, getContentType(File(exportedFile.path).name, previousContentType))
|
||||||
if (Version(it) >= Version("3.17")) {
|
|
||||||
provider = MemoryFileProvider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fileProvider = provider ?: DiskFileProvider()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun exportFile(path: String, size: Long, previousContentType: String? = null): Pair<Uri?, String?> {
|
fun share(files: List<Pair<String, Long>>, volumeId: Int): Pair<Intent?, Int?> {
|
||||||
val fileName = File(path).name
|
|
||||||
val uri = fileProvider.newFile(fileName, size)
|
|
||||||
if (uri != null) {
|
|
||||||
if (encryptedVolume.exportFile(context, path, uri)) {
|
|
||||||
return Pair(uri, getContentType(fileName, previousContentType))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Pair(null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun share(files: List<Pair<String, Long>>): Pair<Intent?, String?> {
|
|
||||||
var contentType: String? = null
|
var contentType: String? = null
|
||||||
val uris = ArrayList<Uri>(files.size)
|
val uris = ArrayList<Uri>(files.size)
|
||||||
for ((path, size) in files) {
|
for ((path, size) in files) {
|
||||||
val result = exportFile(path, size, contentType)
|
val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
|
||||||
contentType = if (result.first == null) {
|
?: return Pair(null, R.string.export_failed_create)
|
||||||
return Pair(null, path)
|
val result = exportFile(exportedFile, size, volumeId, contentType)
|
||||||
|
contentType = if (result == null) {
|
||||||
|
return Pair(null, R.string.export_failed_export)
|
||||||
} else {
|
} else {
|
||||||
uris.add(result.first!!)
|
uris.add(result.first)
|
||||||
result.second
|
result.second
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -79,15 +61,18 @@ class FileShare(private val encryptedVolume: EncryptedVolume, private val contex
|
|||||||
}, null)
|
}, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openWith(path: String, size: Long): Intent? {
|
fun openWith(exportedFile: EncryptedFileProvider.ExportedFile, size: Long, volumeId: Int): Pair<Intent?, Int?> {
|
||||||
val result = exportFile(path, size)
|
val result = exportFile(exportedFile, size, volumeId)
|
||||||
return if (result.first != null) {
|
return if (result == null) {
|
||||||
Intent(Intent.ACTION_VIEW).apply {
|
Pair(null, R.string.export_failed_export)
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
setDataAndType(result.first, result.second)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -4,9 +4,9 @@ import java.io.File
|
|||||||
|
|
||||||
object FileTypes {
|
object FileTypes {
|
||||||
private val FILE_EXTENSIONS = mapOf(
|
private val FILE_EXTENSIONS = mapOf(
|
||||||
Pair("image", listOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "heic")),
|
Pair("image", listOf("png", "jpg", "jpeg", "gif", "avif", "webp", "bmp", "heic")),
|
||||||
Pair("video", listOf("mp4", "webm", "mkv", "mov")),
|
Pair("video", listOf("mp4", "webm", "mkv", "mov", "m4v")),
|
||||||
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac")),
|
Pair("audio", listOf("mp3", "ogg", "m4a", "wav", "flac", "opus")),
|
||||||
Pair("pdf", listOf("pdf")),
|
Pair("pdf", listOf("pdf")),
|
||||||
Pair("text", listOf(
|
Pair("text", listOf(
|
||||||
"asc",
|
"asc",
|
||||||
|
120
app/src/main/java/sushi/hardcore/droidfs/KeepAliveService.kt
Normal file
120
app/src/main/java/sushi/hardcore/droidfs/KeepAliveService.kt
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package sushi.hardcore.droidfs
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
|
||||||
|
class KeepAliveService: Service() {
|
||||||
|
internal class NotificationDetails(
|
||||||
|
val channel: String,
|
||||||
|
val title: String,
|
||||||
|
val text: String,
|
||||||
|
val action: NotificationAction,
|
||||||
|
) : Parcelable {
|
||||||
|
internal class NotificationAction(
|
||||||
|
val icon: Int,
|
||||||
|
val title: String,
|
||||||
|
val action: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
constructor(parcel: Parcel) : this(
|
||||||
|
parcel.readString()!!,
|
||||||
|
parcel.readString()!!,
|
||||||
|
parcel.readString()!!,
|
||||||
|
NotificationAction(
|
||||||
|
parcel.readInt(),
|
||||||
|
parcel.readString()!!,
|
||||||
|
parcel.readString()!!,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||||
|
with (parcel) {
|
||||||
|
writeString(channel)
|
||||||
|
writeString(title)
|
||||||
|
writeString(text)
|
||||||
|
writeInt(action.icon)
|
||||||
|
writeString(action.title)
|
||||||
|
writeString(action.action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun describeContents() = 0
|
||||||
|
|
||||||
|
companion object CREATOR : Parcelable.Creator<NotificationDetails> {
|
||||||
|
override fun createFromParcel(parcel: Parcel) = NotificationDetails(parcel)
|
||||||
|
override fun newArray(size: Int) = arrayOfNulls<NotificationDetails>(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_START = "start"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If [startForeground] is called before notification permission is granted,
|
||||||
|
* the notification won't appear.
|
||||||
|
*
|
||||||
|
* This action can be used once the permission is granted, to make the service
|
||||||
|
* call [startForeground] again in order to properly show the notification.
|
||||||
|
*/
|
||||||
|
const val ACTION_FOREGROUND = "foreground"
|
||||||
|
const val NOTIFICATION_CHANNEL_ID = "KeepAlive"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notificationManager by lazy {
|
||||||
|
NotificationManagerCompat.from(this)
|
||||||
|
}
|
||||||
|
private var notification: Notification? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||||
|
if (intent.action == ACTION_START) {
|
||||||
|
val notificationDetails = IntentCompat.getParcelableExtra(intent, "notification", NotificationDetails::class.java)!!
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
notificationManager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
notificationDetails.channel,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(notificationDetails.title)
|
||||||
|
.setContentText(notificationDetails.text)
|
||||||
|
.addAction(NotificationCompat.Action(
|
||||||
|
notificationDetails.action.icon,
|
||||||
|
notificationDetails.action.title,
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||||
|
action = notificationDetails.action.action
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
ServiceCompat.startForeground(this, startId, notification!!, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC // is there a better use case flag?
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
})
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
88
app/src/main/java/sushi/hardcore/droidfs/LogcatActivity.kt
Normal file
88
app/src/main/java/sushi/hardcore/droidfs/LogcatActivity.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,8 @@
|
|||||||
package sushi.hardcore.droidfs
|
package sushi.hardcore.droidfs
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@ -20,6 +16,7 @@ import kotlinx.coroutines.launch
|
|||||||
import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
|
import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
|
||||||
import sushi.hardcore.droidfs.adapters.VolumeAdapter
|
import sushi.hardcore.droidfs.adapters.VolumeAdapter
|
||||||
import sushi.hardcore.droidfs.add_volume.AddVolumeActivity
|
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.ActivityMainBinding
|
||||||
import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
|
import sushi.hardcore.droidfs.databinding.DialogDeleteVolumeBinding
|
||||||
import sushi.hardcore.droidfs.explorers.ExplorerRouter
|
import sushi.hardcore.droidfs.explorers.ExplorerRouter
|
||||||
@ -27,6 +24,7 @@ import sushi.hardcore.droidfs.file_operations.FileOperationService
|
|||||||
import sushi.hardcore.droidfs.file_operations.TaskResult
|
import sushi.hardcore.droidfs.file_operations.TaskResult
|
||||||
import sushi.hardcore.droidfs.util.IntentUtils
|
import sushi.hardcore.droidfs.util.IntentUtils
|
||||||
import sushi.hardcore.droidfs.util.PathUtils
|
import sushi.hardcore.droidfs.util.PathUtils
|
||||||
|
import sushi.hardcore.droidfs.util.UIUtils
|
||||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@ -129,14 +127,8 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
startService(Intent(this, WiperService::class.java))
|
FileOperationService.bind(this) {
|
||||||
Intent(this, FileOperationService::class.java).also {
|
fileOperationService = it
|
||||||
bindService(it, object : ServiceConnection {
|
|
||||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
|
||||||
fileOperationService = (service as FileOperationService.LocalBinder).getService()
|
|
||||||
}
|
|
||||||
override fun onServiceDisconnected(arg0: ComponentName) {}
|
|
||||||
}, Context.BIND_AUTO_CREATE)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -187,15 +179,13 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun unselect(position: Int) {
|
private fun unselect(position: Int) {
|
||||||
volumeAdapter.selectedItems.remove(position)
|
volumeAdapter.unselect(position)
|
||||||
volumeAdapter.onVolumeChanged(position)
|
|
||||||
onSelectionChanged(0) // unselect() is always called when only one element is selected
|
|
||||||
invalidateOptionsMenu()
|
invalidateOptionsMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removeVolume(volume: VolumeData) {
|
private fun removeVolume(volume: VolumeData) {
|
||||||
volumeManager.getVolumeId(volume)?.let { volumeManager.closeVolume(it) }
|
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) {
|
private fun removeVolumes(volumes: List<VolumeData>, i: Int = 0, doDeleteVolumeContent: Boolean? = null) {
|
||||||
@ -288,7 +278,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
R.id.delete_password_hash -> {
|
R.id.delete_password_hash -> {
|
||||||
for (i in volumeAdapter.selectedItems) {
|
for (i in volumeAdapter.selectedItems) {
|
||||||
if (volumeDatabase.removeHash(volumeAdapter.volumes[i]))
|
if (volumeDatabase.removeHash(volumeAdapter.volumes[i]))
|
||||||
volumeAdapter.onVolumeChanged(i)
|
volumeAdapter.onVolumeDataChanged(i)
|
||||||
}
|
}
|
||||||
unselectAll(false)
|
unselectAll(false)
|
||||||
true
|
true
|
||||||
@ -309,6 +299,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
|
selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
|
||||||
val volume = volumeAdapter.volumes[selectedVolumePosition!!]
|
val volume = volumeAdapter.volumes[selectedVolumePosition!!]
|
||||||
if (volume.isHidden) {
|
if (volume.isHidden) {
|
||||||
|
(application as VolumeManagerApp).isStartingExternalApp = true
|
||||||
PathUtils.safePickDirectory(pickDirectory, this, theme)
|
PathUtils.safePickDirectory(pickDirectory, this, theme)
|
||||||
} else {
|
} else {
|
||||||
val hiddenVolumeFile = File(VolumeData.getHiddenVolumeFullPath(filesDir.path, volume.shortName))
|
val hiddenVolumeFile = File(VolumeData.getHiddenVolumeFullPath(filesDir.path, volume.shortName))
|
||||||
@ -324,7 +315,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
DocumentFile.fromFile(File(volume.name)),
|
DocumentFile.fromFile(File(volume.name)),
|
||||||
DocumentFile.fromFile(hiddenVolumeFile.parentFile!!),
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -346,7 +344,11 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.main_activity, menu)
|
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()
|
val isSelecting = volumeAdapter.selectedItems.isNotEmpty()
|
||||||
menu.findItem(R.id.select_all).isVisible = isSelecting
|
menu.findItem(R.id.select_all).isVisible = isSelecting
|
||||||
menu.findItem(R.id.lock).isVisible = isSelecting && volumeAdapter.selectedItems.any {
|
menu.findItem(R.id.lock).isVisible = isSelecting && volumeAdapter.selectedItems.any {
|
||||||
@ -374,7 +376,7 @@ 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)
|
supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -398,6 +400,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this)
|
val path = PathUtils.getFullPathFromTreeUri(dstRootDirectory.uri, this)
|
||||||
if (path == null) null
|
if (path == null) null
|
||||||
else VolumeData(
|
else VolumeData(
|
||||||
|
VolumeData.newUuid(),
|
||||||
PathUtils.pathJoin(path, name),
|
PathUtils.pathJoin(path, name),
|
||||||
false,
|
false,
|
||||||
volume.type,
|
volume.type,
|
||||||
@ -420,9 +423,6 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
|
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
|
||||||
when (result.taskResult.state) {
|
when (result.taskResult.state) {
|
||||||
TaskResult.State.CANCELLED -> {
|
|
||||||
result.dstRootDirectory?.delete()
|
|
||||||
}
|
|
||||||
TaskResult.State.SUCCESS -> {
|
TaskResult.State.SUCCESS -> {
|
||||||
result.dstRootDirectory?.let {
|
result.dstRootDirectory?.let {
|
||||||
getResultVolume(it)?.let { volume ->
|
getResultVolume(it)?.let { volume ->
|
||||||
@ -444,6 +444,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
|
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
|
||||||
|
TaskResult.State.CANCELLED -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -466,7 +467,9 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
|
|||||||
DocumentFile.fromFile(srcPath).renameTo(newName)
|
DocumentFile.fromFile(srcPath).renameTo(newName)
|
||||||
}
|
}
|
||||||
if (success) {
|
if (success) {
|
||||||
volumeDatabase.renameVolume(volume.name, newDBName)
|
volumeDatabase.renameVolume(volume, newDBName)
|
||||||
|
VolumeProvider.notifyRootsChanged(this)
|
||||||
|
volumeAdapter.onVolumeDataChanged(position)
|
||||||
unselect(position)
|
unselect(position)
|
||||||
if (volume.name == volumeOpener.defaultVolumeName) {
|
if (volume.name == volumeOpener.defaultVolumeName) {
|
||||||
with (sharedPrefs.edit()) {
|
with (sharedPrefs.edit()) {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package sushi.hardcore.droidfs
|
package sushi.hardcore.droidfs
|
||||||
|
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.system.Os
|
||||||
|
|
||||||
class MemFile private constructor(private val fd: Int) {
|
class MemFile private constructor(private val fd: Int) {
|
||||||
companion object {
|
companion object {
|
||||||
@ -15,8 +16,7 @@ class MemFile private constructor(private val fd: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private external fun close(fd: Int)
|
fun dup(): ParcelFileDescriptor = ParcelFileDescriptor.fromFd(fd)
|
||||||
|
fun toParcelFileDescriptor(): ParcelFileDescriptor = ParcelFileDescriptor.adoptFd(fd)
|
||||||
fun getParcelFileDescriptor(): ParcelFileDescriptor = ParcelFileDescriptor.fromFd(fd)
|
fun close() = Os.close(ParcelFileDescriptor.adoptFd(fd).fileDescriptor)
|
||||||
fun close() = close(fd)
|
|
||||||
}
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
package sushi.hardcore.droidfs
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import sushi.hardcore.droidfs.file_operations.FileOperationService
|
||||||
|
|
||||||
|
class NotificationBroadcastReceiver: BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
FileOperationService.ACTION_CANCEL -> {
|
||||||
|
intent.getBundleExtra("bundle")?.let { bundle ->
|
||||||
|
// TODO: use peekService instead?
|
||||||
|
val binder = (bundle.getBinder("binder") as FileOperationService.LocalBinder?)
|
||||||
|
binder?.getService()?.cancelOperation(bundle.getInt("taskId"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VolumeManagerApp.ACTION_CLOSE_ALL_VOLUMES -> {
|
||||||
|
(context.applicationContext as VolumeManagerApp).volumeManager.closeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
package sushi.hardcore.droidfs
|
package sushi.hardcore.droidfs
|
||||||
|
|
||||||
|
import android.app.ActivityOptions
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@ -7,13 +8,23 @@ import android.os.Bundle
|
|||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.preference.*
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.preference.SwitchPreference
|
||||||
|
import androidx.preference.SwitchPreferenceCompat
|
||||||
|
import sushi.hardcore.droidfs.content_providers.VolumeProvider
|
||||||
import sushi.hardcore.droidfs.databinding.ActivitySettingsBinding
|
import sushi.hardcore.droidfs.databinding.ActivitySettingsBinding
|
||||||
|
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||||
|
import sushi.hardcore.droidfs.util.Compat
|
||||||
import sushi.hardcore.droidfs.util.PathUtils
|
import sushi.hardcore.droidfs.util.PathUtils
|
||||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||||
|
|
||||||
class SettingsActivity : BaseActivity() {
|
class SettingsActivity : BaseActivity() {
|
||||||
|
private val notificationPermissionHelper = AndroidUtils.NotificationPermissionHelper(this)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@ -82,9 +93,15 @@ class SettingsActivity : BaseActivity() {
|
|||||||
|
|
||||||
private fun refreshTheme() {
|
private fun refreshTheme() {
|
||||||
with(requireActivity()) {
|
with(requireActivity()) {
|
||||||
startActivity(Intent(this, SettingsActivity::class.java))
|
startActivity(
|
||||||
|
Intent(this, SettingsActivity::class.java),
|
||||||
|
ActivityOptions.makeCustomAnimation(
|
||||||
|
this,
|
||||||
|
android.R.anim.fade_in,
|
||||||
|
android.R.anim.fade_out
|
||||||
|
).toBundle()
|
||||||
|
)
|
||||||
finish()
|
finish()
|
||||||
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,6 +129,10 @@ class SettingsActivity : BaseActivity() {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
findPreference<Preference>("logcat")?.setOnPreferenceClickListener { _ ->
|
||||||
|
startActivity(Intent(requireContext(), LogcatActivity::class.java))
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,6 +171,68 @@ class SettingsActivity : BaseActivity() {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val switchBackground = findPreference<SwitchPreference>("usf_background")!!
|
||||||
|
val switchKeepOpen = findPreference<SwitchPreference>("usf_keep_open")!!
|
||||||
|
val switchExternalOpen = findPreference<SwitchPreference>("usf_open")!!
|
||||||
|
val switchExpose = findPreference<SwitchPreference>("usf_expose")!!
|
||||||
|
val switchSafWrite = findPreference<SwitchPreference>("usf_saf_write")!!
|
||||||
|
|
||||||
|
fun onUsfBackgroundChanged(usfBackground: Boolean) {
|
||||||
|
fun updateSwitchPreference(switch: SwitchPreference) = with (switch) {
|
||||||
|
isChecked = isChecked && usfBackground
|
||||||
|
isEnabled = usfBackground
|
||||||
|
onPreferenceChangeListener?.onPreferenceChange(switch, isChecked)
|
||||||
|
}
|
||||||
|
updateSwitchPreference(switchKeepOpen)
|
||||||
|
updateSwitchPreference(switchExpose)
|
||||||
|
}
|
||||||
|
onUsfBackgroundChanged(switchBackground.isChecked)
|
||||||
|
|
||||||
|
fun updateSafWrite(usfOpen: Boolean? = null, usfExpose: Boolean? = null) {
|
||||||
|
switchSafWrite.isEnabled = usfOpen ?: switchExternalOpen.isChecked || usfExpose ?: switchExpose.isChecked
|
||||||
|
}
|
||||||
|
updateSafWrite()
|
||||||
|
|
||||||
|
switchBackground.setOnPreferenceChangeListener { _, checked ->
|
||||||
|
onUsfBackgroundChanged(checked as Boolean)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
switchExternalOpen.setOnPreferenceChangeListener { _, checked ->
|
||||||
|
updateSafWrite(usfOpen = checked as Boolean)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
switchExpose.setOnPreferenceChangeListener { _, checked ->
|
||||||
|
updateSafWrite(usfExpose = checked as Boolean)
|
||||||
|
VolumeProvider.notifyRootsChanged(requireContext())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
switchKeepOpen.setOnPreferenceChangeListener { _, checked ->
|
||||||
|
if (checked as Boolean) {
|
||||||
|
(requireActivity() as SettingsActivity).notificationPermissionHelper.askAndRun {
|
||||||
|
requireContext().let {
|
||||||
|
if (AndroidUtils.isServiceRunning(it, KeepAliveService::class.java)) {
|
||||||
|
ContextCompat.startForegroundService(it, Intent(it, KeepAliveService::class.java).apply {
|
||||||
|
action = KeepAliveService.ACTION_FOREGROUND
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
findPreference<ListPreference>("export_method")!!.setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
if (newValue as String == "memory" && !Compat.isMemFileSupported()) {
|
||||||
|
CustomAlertDialogBuilder(requireContext(), (requireActivity() as BaseActivity).theme)
|
||||||
|
.setTitle(R.string.error)
|
||||||
|
.setMessage(getString(R.string.memfd_create_unsupported, Compat.MEMFD_CREATE_MINIMUM_KERNEL_VERSION))
|
||||||
|
.setPositiveButton(R.string.ok, null)
|
||||||
|
.show()
|
||||||
|
return@setOnPreferenceChangeListener false
|
||||||
|
}
|
||||||
|
EncryptedFileProvider.exportMethod = EncryptedFileProvider.ExportMethod.parse(newValue)
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,10 +8,19 @@ import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
|||||||
import sushi.hardcore.droidfs.util.PathUtils
|
import sushi.hardcore.droidfs.util.PathUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
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(
|
constructor(parcel: Parcel) : this(
|
||||||
|
parcel.readString()!!,
|
||||||
parcel.readString()!!,
|
parcel.readString()!!,
|
||||||
parcel.readByte() != 0.toByte(),
|
parcel.readByte() != 0.toByte(),
|
||||||
parcel.readByte(),
|
parcel.readByte(),
|
||||||
@ -23,12 +32,7 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
|
|||||||
File(name).name
|
File(name).name
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getFullPath(filesDir: String): String {
|
fun getFullPath(filesDir: String) = getFullPath(name, isHidden, filesDir)
|
||||||
return if (isHidden)
|
|
||||||
getHiddenVolumeFullPath(filesDir, name)
|
|
||||||
else
|
|
||||||
name
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canRead(filesDir: String): Boolean {
|
fun canRead(filesDir: String): Boolean {
|
||||||
val volumePath = getFullPath(filesDir)
|
val volumePath = getFullPath(filesDir)
|
||||||
@ -62,6 +66,7 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
|
|||||||
|
|
||||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||||
with (dest) {
|
with (dest) {
|
||||||
|
writeString(uuid)
|
||||||
writeString(name)
|
writeString(name)
|
||||||
writeByte(if (isHidden) 1 else 0)
|
writeByte(if (isHidden) 1 else 0)
|
||||||
writeByte(type)
|
writeByte(type)
|
||||||
@ -74,7 +79,7 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
|
|||||||
if (other !is VolumeData) {
|
if (other !is VolumeData) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return other.name == name && other.isHidden == isHidden
|
return other.uuid == uuid || (other.name == name && other.isHidden == isHidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
@ -90,8 +95,17 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
|
|||||||
override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size)
|
override fun newArray(size: Int) = arrayOfNulls<VolumeData>(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun newUuid(): String = UUID.randomUUID().toString()
|
||||||
|
|
||||||
fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
|
fun getHiddenVolumeFullPath(filesDir: String, name: String): String {
|
||||||
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name)
|
return PathUtils.pathJoin(filesDir, VOLUMES_DIRECTORY, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getFullPath(name: String, isHidden: Boolean, filesDir: String): String {
|
||||||
|
return if (isHidden)
|
||||||
|
getHiddenVolumeFullPath(filesDir, name)
|
||||||
|
else
|
||||||
|
name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -10,46 +10,69 @@ import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
|||||||
import sushi.hardcore.droidfs.util.PathUtils
|
import sushi.hardcore.droidfs.util.PathUtils
|
||||||
import java.io.File
|
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 {
|
companion object {
|
||||||
const val TAG = "VolumeDatabase"
|
private const val TAG = "VolumeDatabase"
|
||||||
const val TABLE_NAME = "Volumes"
|
private const val TABLE_NAME = "Volumes"
|
||||||
const val COLUMN_NAME = "name"
|
private const val COLUMN_UUID = "uuid"
|
||||||
const val COLUMN_HIDDEN = "hidden"
|
private const val COLUMN_NAME = "name"
|
||||||
const val COLUMN_TYPE = "type"
|
private const val COLUMN_HIDDEN = "hidden"
|
||||||
const val COLUMN_HASH = "hash"
|
private const val COLUMN_TYPE = "type"
|
||||||
const val COLUMN_IV = "iv"
|
private const val COLUMN_HASH = "hash"
|
||||||
|
private 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
override fun onCreate(db: SQLiteDatabase) {
|
|
||||||
|
private fun createTable(db: SQLiteDatabase) =
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
|
"CREATE TABLE IF NOT EXISTS $TABLE_NAME (" +
|
||||||
"$COLUMN_NAME TEXT PRIMARY KEY," +
|
"$COLUMN_UUID TEXT PRIMARY KEY," +
|
||||||
"$COLUMN_HIDDEN SHORT," +
|
"$COLUMN_NAME TEXT," +
|
||||||
"$COLUMN_TYPE BLOB," +
|
"$COLUMN_HIDDEN SHORT," +
|
||||||
"$COLUMN_HASH BLOB," +
|
"$COLUMN_TYPE BLOB," +
|
||||||
"$COLUMN_IV BLOB" +
|
"$COLUMN_HASH BLOB," +
|
||||||
");"
|
"$COLUMN_IV BLOB" +
|
||||||
|
");"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
|
createTable(db)
|
||||||
File(context.filesDir, VolumeData.VOLUMES_DIRECTORY).mkdir()
|
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 {
|
private fun getNewVolumePath(volumeName: String): File {
|
||||||
return File(
|
return File(
|
||||||
VolumeData(
|
VolumeData.getFullPath(volumeName, true, context.filesDir.path)
|
||||||
volumeName,
|
|
||||||
true,
|
|
||||||
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE
|
|
||||||
).getFullPath(context.filesDir.path)
|
|
||||||
).canonicalFile
|
).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 {
|
private fun extractVolumeData(cursor: Cursor): VolumeData {
|
||||||
return VolumeData(
|
return VolumeData(
|
||||||
|
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_UUID)),
|
||||||
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)),
|
cursor.getString(cursor.getColumnIndexOrThrow(COLUMN_NAME)),
|
||||||
cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(),
|
cursor.getShort(cursor.getColumnIndexOrThrow(COLUMN_HIDDEN)) == 1.toShort(),
|
||||||
cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_TYPE))[0],
|
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 {
|
fun saveVolume(volume: VolumeData): Boolean {
|
||||||
if (!isVolumeSaved(volume.name, volume.isHidden)) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
@ -157,8 +214,8 @@ class VolumeDatabase(private val context: Context): SQLiteOpenHelper(context, Co
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isHashSaved(volumeName: String): Boolean {
|
fun isHashSaved(volume: VolumeData): Boolean {
|
||||||
val cursor = readableDatabase.query(TABLE_NAME, arrayOf(COLUMN_NAME, COLUMN_HASH), "$COLUMN_NAME=?", arrayOf(volumeName), null, null, null)
|
val cursor = readableDatabase.rawQuery("SELECT $COLUMN_HASH FROM $TABLE_NAME WHERE $COLUMN_UUID=?", arrayOf(volume.uuid))
|
||||||
var isHashSaved = false
|
var isHashSaved = false
|
||||||
if (cursor.moveToNext()) {
|
if (cursor.moveToNext()) {
|
||||||
if (cursor.getBlob(cursor.getColumnIndexOrThrow(COLUMN_HASH)) != null) {
|
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 {
|
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 {
|
fun removeHash(volume: VolumeData): Boolean {
|
||||||
return writableDatabase.update(
|
return writableDatabase.update(
|
||||||
TABLE_NAME, contentValuesFromVolume(
|
TABLE_NAME,
|
||||||
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,
|
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put(COLUMN_NAME, newName)
|
put(COLUMN_HASH, null as ByteArray?)
|
||||||
},
|
put(COLUMN_IV, null as ByteArray?)
|
||||||
"$COLUMN_NAME=?",arrayOf(oldName)
|
}, "$COLUMN_UUID=?", arrayOf(volume.uuid)
|
||||||
) > 0
|
) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeVolume(volumeName: String): Boolean {
|
fun renameVolume(volume: VolumeData, newName: String): Boolean {
|
||||||
return writableDatabase.delete(TABLE_NAME, "$COLUMN_NAME=?", arrayOf(volumeName)) > 0
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,12 +1,20 @@
|
|||||||
package sushi.hardcore.droidfs
|
package sushi.hardcore.droidfs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import sushi.hardcore.droidfs.content_providers.VolumeProvider
|
||||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
|
import sushi.hardcore.droidfs.util.Observable
|
||||||
|
|
||||||
|
class VolumeManager(private val context: Context): Observable<VolumeManager.Observer>() {
|
||||||
|
interface Observer {
|
||||||
|
fun onVolumeStateChanged(volume: VolumeData) {}
|
||||||
|
fun onAllVolumesClosed() {}
|
||||||
|
}
|
||||||
|
|
||||||
class VolumeManager {
|
|
||||||
private var id = 0
|
private var id = 0
|
||||||
private val volumes = HashMap<Int, EncryptedVolume>()
|
private val volumes = HashMap<Int, EncryptedVolume>()
|
||||||
private val volumesData = HashMap<VolumeData, Int>()
|
private val volumesData = HashMap<VolumeData, Int>()
|
||||||
@ -15,6 +23,8 @@ class VolumeManager {
|
|||||||
fun insert(volume: EncryptedVolume, data: VolumeData): Int {
|
fun insert(volume: EncryptedVolume, data: VolumeData): Int {
|
||||||
volumes[id] = volume
|
volumes[id] = volume
|
||||||
volumesData[data] = id
|
volumesData[data] = id
|
||||||
|
observers.forEach { it.onVolumeStateChanged(data) }
|
||||||
|
VolumeProvider.notifyRootsChanged(context)
|
||||||
return id++
|
return id++
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +40,12 @@ class VolumeManager {
|
|||||||
return volumes[id]
|
return volumes[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun listVolumes(): List<Pair<Int, VolumeData>> {
|
||||||
|
return volumesData.map { (data, id) -> Pair(id, data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getVolumeCount() = volumes.size
|
||||||
|
|
||||||
fun getCoroutineScope(volumeId: Int): CoroutineScope {
|
fun getCoroutineScope(volumeId: Int): CoroutineScope {
|
||||||
return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it }
|
return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it }
|
||||||
}
|
}
|
||||||
@ -37,19 +53,23 @@ class VolumeManager {
|
|||||||
fun closeVolume(id: Int) {
|
fun closeVolume(id: Int) {
|
||||||
volumes.remove(id)?.let { volume ->
|
volumes.remove(id)?.let { volume ->
|
||||||
scopes[id]?.cancel()
|
scopes[id]?.cancel()
|
||||||
volume.close()
|
volume.closeVolume()
|
||||||
volumesData.filter { it.value == id }.forEach {
|
volumesData.filter { it.value == id }.forEach { entry ->
|
||||||
volumesData.remove(it.key)
|
volumesData.remove(entry.key)
|
||||||
|
observers.forEach { it.onVolumeStateChanged(entry.key) }
|
||||||
}
|
}
|
||||||
|
VolumeProvider.notifyRootsChanged(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeAll() {
|
fun closeAll() {
|
||||||
volumes.forEach {
|
volumes.forEach {
|
||||||
scopes[it.key]?.cancel()
|
scopes[it.key]?.cancel()
|
||||||
it.value.close()
|
it.value.closeVolume()
|
||||||
}
|
}
|
||||||
volumes.clear()
|
volumes.clear()
|
||||||
volumesData.clear()
|
volumesData.clear()
|
||||||
|
observers.forEach { it.onAllVolumesClosed() }
|
||||||
|
VolumeProvider.notifyRootsChanged(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,40 +1,88 @@
|
|||||||
package sushi.hardcore.droidfs
|
package sushi.hardcore.droidfs
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.SharedPreferences
|
import android.content.Intent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import androidx.preference.PreferenceManager
|
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
|
||||||
import sushi.hardcore.droidfs.content_providers.MemoryFileProvider
|
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||||
import sushi.hardcore.droidfs.content_providers.DiskFileProvider
|
|
||||||
|
|
||||||
class VolumeManagerApp : Application(), DefaultLifecycleObserver {
|
class VolumeManagerApp : Application(), DefaultLifecycleObserver {
|
||||||
companion object {
|
companion object {
|
||||||
private const val USF_KEEP_OPEN_KEY = "usf_keep_open"
|
const val ACTION_CLOSE_ALL_VOLUMES = "close_all"
|
||||||
}
|
}
|
||||||
|
|
||||||
lateinit var sharedPreferences: SharedPreferences
|
private val closingServiceIntent by lazy {
|
||||||
private val sharedPreferencesListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
Intent(this, ClosingService::class.java)
|
||||||
if (key == USF_KEEP_OPEN_KEY) {
|
|
||||||
reloadUsfKeepOpen()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
private var usfKeepOpen = false
|
private val keepAliveServiceStartIntent by lazy {
|
||||||
|
Intent(this, KeepAliveService::class.java).apply {
|
||||||
|
action = KeepAliveService.ACTION_START
|
||||||
|
}.putExtra(
|
||||||
|
"notification", KeepAliveService.NotificationDetails(
|
||||||
|
"KeepAlive",
|
||||||
|
getString(R.string.keep_alive_notification_title),
|
||||||
|
getString(R.string.keep_alive_notification_text),
|
||||||
|
KeepAliveService.NotificationDetails.NotificationAction(
|
||||||
|
R.drawable.icon_lock,
|
||||||
|
getString(R.string.close_all),
|
||||||
|
ACTION_CLOSE_ALL_VOLUMES,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
private val usfBackgroundDelegate = AndroidUtils.LiveBooleanPreference("usf_background", false) { _ ->
|
||||||
|
updateServicesStates()
|
||||||
|
}
|
||||||
|
private val usfBackground by usfBackgroundDelegate
|
||||||
|
private val usfKeepOpenDelegate = AndroidUtils.LiveBooleanPreference("usf_keep_open", false) { _ ->
|
||||||
|
updateServicesStates()
|
||||||
|
}
|
||||||
|
private val usfKeepOpen by usfKeepOpenDelegate
|
||||||
|
var isExporting = false
|
||||||
var isStartingExternalApp = false
|
var isStartingExternalApp = false
|
||||||
val volumeManager = VolumeManager()
|
val volumeManager = VolumeManager(this).also {
|
||||||
|
it.observe(object : VolumeManager.Observer {
|
||||||
|
override fun onVolumeStateChanged(volume: VolumeData) {
|
||||||
|
updateServicesStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAllVolumesClosed() {
|
||||||
|
stopKeepAliveService()
|
||||||
|
// closingService should not be running when this callback is triggered
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super<Application>.onCreate()
|
super<Application>.onCreate()
|
||||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this).apply {
|
AndroidUtils.LiveBooleanPreference.init(this, usfBackgroundDelegate, usfKeepOpenDelegate)
|
||||||
registerOnSharedPreferenceChangeListener(sharedPreferencesListener)
|
|
||||||
}
|
|
||||||
reloadUsfKeepOpen()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reloadUsfKeepOpen() {
|
fun updateServicesStates() {
|
||||||
usfKeepOpen = sharedPreferences.getBoolean(USF_KEEP_OPEN_KEY, false)
|
if (usfBackground && volumeManager.getVolumeCount() > 0) {
|
||||||
|
if (usfKeepOpen) {
|
||||||
|
stopService(closingServiceIntent)
|
||||||
|
if (!AndroidUtils.isServiceRunning(this@VolumeManagerApp, KeepAliveService::class.java)) {
|
||||||
|
ContextCompat.startForegroundService(this, keepAliveServiceStartIntent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopKeepAliveService()
|
||||||
|
if (!AndroidUtils.isServiceRunning(this@VolumeManagerApp, ClosingService::class.java)) {
|
||||||
|
startService(closingServiceIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopService(closingServiceIntent)
|
||||||
|
stopKeepAliveService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopKeepAliveService() {
|
||||||
|
stopService(Intent(this, KeepAliveService::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume(owner: LifecycleOwner) {
|
override fun onResume(owner: LifecycleOwner) {
|
||||||
@ -43,11 +91,12 @@ class VolumeManagerApp : Application(), DefaultLifecycleObserver {
|
|||||||
|
|
||||||
override fun onStop(owner: LifecycleOwner) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
if (!isStartingExternalApp) {
|
if (!isStartingExternalApp) {
|
||||||
if (!usfKeepOpen) {
|
if (!usfBackground) {
|
||||||
volumeManager.closeAll()
|
volumeManager.closeAll()
|
||||||
}
|
}
|
||||||
DiskFileProvider.wipe()
|
if (!usfBackground || !isExporting) {
|
||||||
MemoryFileProvider.wipe()
|
TemporaryFileProvider.instance.wipe()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -13,7 +13,7 @@ import sushi.hardcore.droidfs.Constants.DEFAULT_VOLUME_KEY
|
|||||||
import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding
|
import sushi.hardcore.droidfs.databinding.DialogOpenVolumeBinding
|
||||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
import sushi.hardcore.droidfs.util.ObjRef
|
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 sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@ -123,7 +123,7 @@ class VolumeOpener(
|
|||||||
apply()
|
apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val password = WidgetUtil.encodeEditTextContent(dialogBinding!!.editPassword)
|
val password = UIUtils.encodeEditTextContent(dialogBinding!!.editPassword)
|
||||||
val savePasswordHash = dialogBinding!!.checkboxSavePassword.isChecked
|
val savePasswordHash = dialogBinding!!.checkboxSavePassword.isChecked
|
||||||
dialogBinding = null
|
dialogBinding = null
|
||||||
// openVolumeWithPassword is responsible for wiping the password
|
// openVolumeWithPassword is responsible for wiping the password
|
||||||
@ -211,7 +211,7 @@ class VolumeOpener(
|
|||||||
private var isClosed = false
|
private var isClosed = false
|
||||||
override fun onFailed(pending: Boolean) {
|
override fun onFailed(pending: Boolean) {
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
encryptedVolume.close()
|
encryptedVolume.closeVolume()
|
||||||
isClosed = true
|
isClosed = true
|
||||||
}
|
}
|
||||||
Arrays.fill(returnedHash.value!!, 0)
|
Arrays.fill(returnedHash.value!!, 0)
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
package sushi.hardcore.droidfs
|
|
||||||
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.IBinder
|
|
||||||
|
|
||||||
class WiperService : Service() {
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent) {
|
|
||||||
super.onTaskRemoved(rootIntent)
|
|
||||||
(application as VolumeManagerApp).volumeManager.closeAll()
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
@ -40,6 +40,12 @@ abstract class SelectableAdapter<T>(private val onSelectionChanged: (Int) -> Uni
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun unselect(position: Int) {
|
||||||
|
selectedItems.remove(position)
|
||||||
|
onSelectionChanged(selectedItems.size)
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
|
||||||
fun selectAll() {
|
fun selectAll() {
|
||||||
for (i in getItems().indices) {
|
for (i in getItems().indices) {
|
||||||
if (!selectedItems.contains(i) && isSelectable(i)) {
|
if (!selectedItems.contains(i) && isSelectable(i)) {
|
||||||
|
@ -29,6 +29,16 @@ class VolumeAdapter(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
reloadVolumes()
|
reloadVolumes()
|
||||||
|
volumeManager.observe(object : VolumeManager.Observer {
|
||||||
|
override fun onVolumeStateChanged(volume: VolumeData) {
|
||||||
|
notifyItemChanged(volumes.indexOf(volume))
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
|
override fun onAllVolumesClosed() {
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Listener {
|
interface Listener {
|
||||||
@ -66,7 +76,7 @@ class VolumeAdapter(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onVolumeChanged(position: Int) {
|
fun onVolumeDataChanged(position: Int) {
|
||||||
reloadVolumes()
|
reloadVolumes()
|
||||||
notifyItemChanged(position)
|
notifyItemChanged(position)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,16 @@
|
|||||||
package sushi.hardcore.droidfs.add_volume
|
package sushi.hardcore.droidfs.add_volume
|
||||||
|
|
||||||
|
import sushi.hardcore.droidfs.R
|
||||||
|
|
||||||
enum class Action {
|
enum class Action {
|
||||||
|
OPEN,
|
||||||
ADD,
|
ADD,
|
||||||
CREATE,
|
CREATE,
|
||||||
|
;
|
||||||
|
|
||||||
|
fun getStringResId() = when (this) {
|
||||||
|
OPEN -> R.string.open
|
||||||
|
ADD -> R.string.add_volume
|
||||||
|
CREATE -> R.string.create_volume
|
||||||
|
}
|
||||||
}
|
}
|
@ -67,17 +67,17 @@ class AddVolumeActivity: BaseActivity() {
|
|||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onVolumeSelected(volume: VolumeData, rememberVolume: Boolean) {
|
fun onVolumeAdded() {
|
||||||
if (rememberVolume) {
|
setResult(RESULT_USER_BACK)
|
||||||
setResult(RESULT_USER_BACK)
|
finish()
|
||||||
finish()
|
}
|
||||||
} else {
|
|
||||||
volumeOpener.openVolume(volume, false, object : VolumeOpener.VolumeOpenerCallbacks {
|
fun openVolume(volume: VolumeData, isVolumeKnown: Boolean) {
|
||||||
override fun onVolumeOpened(id: Int) {
|
volumeOpener.openVolume(volume, isVolumeKnown, object : VolumeOpener.VolumeOpenerCallbacks {
|
||||||
startExplorer(id, volume.shortName)
|
override fun onVolumeOpened(id: Int) {
|
||||||
}
|
startExplorer(id, volume.shortName)
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createVolume(volumePath: String, isHidden: Boolean, rememberVolume: Boolean) {
|
fun createVolume(volumePath: String, isHidden: Boolean, rememberVolume: Boolean) {
|
||||||
|
@ -7,25 +7,37 @@ import android.text.InputType
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.AdapterView
|
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.RadioButton
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.children
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import sushi.hardcore.droidfs.*
|
import sushi.hardcore.droidfs.BuildConfig
|
||||||
|
import sushi.hardcore.droidfs.Constants
|
||||||
|
import sushi.hardcore.droidfs.FingerprintProtector
|
||||||
|
import sushi.hardcore.droidfs.LoadingTask
|
||||||
|
import sushi.hardcore.droidfs.R
|
||||||
|
import sushi.hardcore.droidfs.Theme
|
||||||
|
import sushi.hardcore.droidfs.VolumeData
|
||||||
|
import sushi.hardcore.droidfs.VolumeDatabase
|
||||||
|
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||||
|
import sushi.hardcore.droidfs.databinding.FileSystemRadioBinding
|
||||||
import sushi.hardcore.droidfs.databinding.FragmentCreateVolumeBinding
|
import sushi.hardcore.droidfs.databinding.FragmentCreateVolumeBinding
|
||||||
import sushi.hardcore.droidfs.filesystems.CryfsVolume
|
import sushi.hardcore.droidfs.filesystems.CryfsVolume
|
||||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
|
||||||
import sushi.hardcore.droidfs.util.Compat
|
import sushi.hardcore.droidfs.util.Compat
|
||||||
import sushi.hardcore.droidfs.util.ObjRef
|
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 sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.*
|
import java.util.Arrays
|
||||||
|
|
||||||
class CreateVolumeFragment: Fragment() {
|
class CreateVolumeFragment: Fragment() {
|
||||||
|
internal data class FileSystemInfo(val nameResource: Int, val detailsResource: Int, val ciphersResource: Int)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_THEME_VALUE = "theme"
|
private const val KEY_THEME_VALUE = "theme"
|
||||||
private const val KEY_VOLUME_PATH = "path"
|
private const val KEY_VOLUME_PATH = "path"
|
||||||
@ -34,6 +46,17 @@ class CreateVolumeFragment: Fragment() {
|
|||||||
private const val KEY_PIN_PASSWORDS = Constants.PIN_PASSWORDS_KEY
|
private const val KEY_PIN_PASSWORDS = Constants.PIN_PASSWORDS_KEY
|
||||||
private const val KEY_USF_FINGERPRINT = "fingerprint"
|
private const val KEY_USF_FINGERPRINT = "fingerprint"
|
||||||
|
|
||||||
|
private val GOCRYPTFS_INFO = FileSystemInfo(
|
||||||
|
R.string.gocryptfs,
|
||||||
|
R.string.gocryptfs_details,
|
||||||
|
R.array.gocryptfs_encryption_ciphers,
|
||||||
|
)
|
||||||
|
private val CRYFS_INFO = FileSystemInfo(
|
||||||
|
R.string.cryfs,
|
||||||
|
R.string.cryfs_details,
|
||||||
|
R.array.cryfs_encryption_ciphers,
|
||||||
|
)
|
||||||
|
|
||||||
fun newInstance(
|
fun newInstance(
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
volumePath: String,
|
volumePath: String,
|
||||||
@ -57,7 +80,7 @@ class CreateVolumeFragment: Fragment() {
|
|||||||
|
|
||||||
private lateinit var binding: FragmentCreateVolumeBinding
|
private lateinit var binding: FragmentCreateVolumeBinding
|
||||||
private lateinit var theme: Theme
|
private lateinit var theme: Theme
|
||||||
private val volumeTypes = ArrayList<String>(2)
|
private val fileSystemInfos = ArrayList<FileSystemInfo>(2)
|
||||||
private lateinit var volumePath: String
|
private lateinit var volumePath: String
|
||||||
private var isHiddenVolume: Boolean = false
|
private var isHiddenVolume: Boolean = false
|
||||||
private var rememberVolume: Boolean = false
|
private var rememberVolume: Boolean = false
|
||||||
@ -92,17 +115,10 @@ class CreateVolumeFragment: Fragment() {
|
|||||||
binding.checkboxSavePassword.visibility = View.GONE
|
binding.checkboxSavePassword.visibility = View.GONE
|
||||||
}
|
}
|
||||||
if (!BuildConfig.GOCRYPTFS_DISABLED) {
|
if (!BuildConfig.GOCRYPTFS_DISABLED) {
|
||||||
volumeTypes.add(resources.getString(R.string.gocryptfs))
|
fileSystemInfos.add(GOCRYPTFS_INFO)
|
||||||
}
|
}
|
||||||
if (!BuildConfig.CRYFS_DISABLED) {
|
if (!BuildConfig.CRYFS_DISABLED) {
|
||||||
volumeTypes.add(resources.getString(R.string.cryfs))
|
fileSystemInfos.add(CRYFS_INFO)
|
||||||
}
|
|
||||||
binding.spinnerVolumeType.adapter = ArrayAdapter(
|
|
||||||
requireContext(),
|
|
||||||
android.R.layout.simple_spinner_item,
|
|
||||||
volumeTypes
|
|
||||||
).apply {
|
|
||||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
|
||||||
}
|
}
|
||||||
val encryptionCipherAdapter = ArrayAdapter(
|
val encryptionCipherAdapter = ArrayAdapter(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
@ -111,19 +127,29 @@ class CreateVolumeFragment: Fragment() {
|
|||||||
).apply {
|
).apply {
|
||||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||||
}
|
}
|
||||||
binding.spinnerVolumeType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
for ((i, fs) in fileSystemInfos.iterator().withIndex()) {
|
||||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
with(FileSystemRadioBinding.inflate(layoutInflater)) {
|
||||||
val ciphersArray = if (volumeTypes[position] == resources.getString(R.string.gocryptfs)) {
|
title.text = getString(fs.nameResource)
|
||||||
R.array.gocryptfs_encryption_ciphers
|
details.text = getString(fs.detailsResource)
|
||||||
} else {
|
radio.isChecked = i == 0
|
||||||
R.array.cryfs_encryption_ciphers
|
root.setOnClickListener {
|
||||||
|
radio.performClick()
|
||||||
}
|
}
|
||||||
with(encryptionCipherAdapter) {
|
radio.setOnCheckedChangeListener { _, isChecked ->
|
||||||
clear()
|
if (isChecked) {
|
||||||
addAll(resources.getStringArray(ciphersArray).asList())
|
with(encryptionCipherAdapter) {
|
||||||
|
clear()
|
||||||
|
addAll(resources.getStringArray(fs.ciphersResource).asList())
|
||||||
|
}
|
||||||
|
binding.radioGroupFilesystems.children.forEach {
|
||||||
|
if (it != root) {
|
||||||
|
it.findViewById<RadioButton>(R.id.radio).isChecked = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
binding.radioGroupFilesystems.addView(root)
|
||||||
}
|
}
|
||||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
|
||||||
}
|
}
|
||||||
binding.spinnerCipher.adapter = encryptionCipherAdapter
|
binding.spinnerCipher.adapter = encryptionCipherAdapter
|
||||||
if (pinPasswords) {
|
if (pinPasswords) {
|
||||||
@ -145,9 +171,18 @@ class CreateVolumeFragment: Fragment() {
|
|||||||
(activity as AddVolumeActivity).onFragmentLoaded(false)
|
(activity as AddVolumeActivity).onFragmentLoaded(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getSelectedFileSystemIndex(): Int {
|
||||||
|
for ((i, child) in binding.radioGroupFilesystems.children.iterator().withIndex()) {
|
||||||
|
if (child.findViewById<RadioButton>(R.id.radio).isChecked) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
private fun createVolume() {
|
private fun createVolume() {
|
||||||
val password = WidgetUtil.encodeEditTextContent(binding.editPassword)
|
val password = UIUtils.encodeEditTextContent(binding.editPassword)
|
||||||
val passwordConfirm = WidgetUtil.encodeEditTextContent(binding.editPasswordConfirm)
|
val passwordConfirm = UIUtils.encodeEditTextContent(binding.editPasswordConfirm)
|
||||||
if (!password.contentEquals(passwordConfirm)) {
|
if (!password.contentEquals(passwordConfirm)) {
|
||||||
Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.passwords_mismatch, Toast.LENGTH_SHORT).show()
|
||||||
Arrays.fill(password, 0)
|
Arrays.fill(password, 0)
|
||||||
@ -173,11 +208,11 @@ class CreateVolumeFragment: Fragment() {
|
|||||||
val volumeFile = File(volumePath)
|
val volumeFile = File(volumePath)
|
||||||
if (!volumeFile.exists())
|
if (!volumeFile.exists())
|
||||||
volumeFile.mkdirs()
|
volumeFile.mkdirs()
|
||||||
val result = if (volumeTypes[binding.spinnerVolumeType.selectedItemPosition] == resources.getString(R.string.gocryptfs)) {
|
val result = if (fileSystemInfos[getSelectedFileSystemIndex()] == GOCRYPTFS_INFO) {
|
||||||
val xchacha = when (binding.spinnerCipher.selectedItemPosition) {
|
val xchacha = when (binding.spinnerCipher.selectedItemPosition) {
|
||||||
0 -> 0
|
0 -> -1 // auto
|
||||||
1 -> 1
|
1 -> 0 // AES-GCM
|
||||||
else -> -1
|
else -> 1 // XChaCha20-Poly1305
|
||||||
}
|
}
|
||||||
generateResult(GocryptfsVolume.createAndOpenVolume(
|
generateResult(GocryptfsVolume.createAndOpenVolume(
|
||||||
volumePath,
|
volumePath,
|
||||||
@ -211,11 +246,11 @@ class CreateVolumeFragment: Fragment() {
|
|||||||
.show()
|
.show()
|
||||||
} else {
|
} else {
|
||||||
val volumeName = if (isHiddenVolume) File(volumePath).name else volumePath
|
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
|
var isVolumeSaved = false
|
||||||
volumeDatabase.apply {
|
volumeDatabase.apply {
|
||||||
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
|
if (isVolumeSaved(volumeName, isHiddenVolume)) // cleaning old saved path
|
||||||
removeVolume(volumeName)
|
removeVolume(volume)
|
||||||
if (rememberVolume) {
|
if (rememberVolume) {
|
||||||
isVolumeSaved = saveVolume(volume)
|
isVolumeSaved = saveVolume(volume)
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package sushi.hardcore.droidfs.add_volume
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@ -15,10 +16,14 @@ import android.text.TextWatcher
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import sushi.hardcore.droidfs.Constants
|
import sushi.hardcore.droidfs.Constants
|
||||||
import sushi.hardcore.droidfs.R
|
import sushi.hardcore.droidfs.R
|
||||||
@ -35,6 +40,10 @@ import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class SelectPathFragment: Fragment() {
|
class SelectPathFragment: Fragment() {
|
||||||
|
internal class InputViewModel: ViewModel() {
|
||||||
|
var showEditText = false
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_THEME_VALUE = "theme"
|
private const val KEY_THEME_VALUE = "theme"
|
||||||
private const val KEY_PICK_MODE = "pick"
|
private const val KEY_PICK_MODE = "pick"
|
||||||
@ -74,6 +83,7 @@ class SelectPathFragment: Fragment() {
|
|||||||
private var originalRememberVolume = true
|
private var originalRememberVolume = true
|
||||||
private var currentVolumeData: VolumeData? = null
|
private var currentVolumeData: VolumeData? = null
|
||||||
private var volumeAction: Action? = null
|
private var volumeAction: Action? = null
|
||||||
|
private val inputViewModel: InputViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
@ -93,17 +103,13 @@ class SelectPathFragment: Fragment() {
|
|||||||
theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
|
theme = Compat.getParcelable(arguments, KEY_THEME_VALUE)!!
|
||||||
pickMode = arguments.getBoolean(KEY_PICK_MODE)
|
pickMode = arguments.getBoolean(KEY_PICK_MODE)
|
||||||
}
|
}
|
||||||
if (pickMode) {
|
|
||||||
binding.buttonAction.text = getString(R.string.add_volume)
|
|
||||||
}
|
|
||||||
volumeDatabase = VolumeDatabase(requireContext())
|
volumeDatabase = VolumeDatabase(requireContext())
|
||||||
filesDir = requireContext().filesDir.path
|
filesDir = requireContext().filesDir.path
|
||||||
binding.containerHiddenVolume.setOnClickListener {
|
binding.containerHiddenVolume.setOnClickListener {
|
||||||
binding.switchHiddenVolume.performClick()
|
binding.switchHiddenVolume.performClick()
|
||||||
}
|
}
|
||||||
binding.switchHiddenVolume.setOnClickListener {
|
binding.switchHiddenVolume.setOnClickListener {
|
||||||
showRightSection()
|
updateUi()
|
||||||
refreshStatus(binding.editVolumeName.text)
|
|
||||||
}
|
}
|
||||||
binding.buttonPickDirectory.setOnClickListener {
|
binding.buttonPickDirectory.setOnClickListener {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
@ -137,22 +143,41 @@ class SelectPathFragment: Fragment() {
|
|||||||
launchPickDirectory()
|
launchPickDirectory()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
binding.buttonEnterPath.setOnClickListener {
|
||||||
|
inputViewModel.showEditText = true
|
||||||
|
updateUi()
|
||||||
|
binding.editVolumeName.requestFocus()
|
||||||
|
(app.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(
|
||||||
|
binding.editVolumeName,
|
||||||
|
InputMethodManager.SHOW_IMPLICIT
|
||||||
|
)
|
||||||
|
}
|
||||||
binding.editVolumeName.addTextChangedListener(object: TextWatcher {
|
binding.editVolumeName.addTextChangedListener(object: TextWatcher {
|
||||||
override fun afterTextChanged(s: Editable?) {}
|
override fun afterTextChanged(s: Editable?) {}
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
|
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
|
||||||
refreshStatus(s)
|
updateUi(s)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
binding.switchRemember.setOnCheckedChangeListener { _, _ -> refreshButtonText() }
|
binding.switchRemember.setOnCheckedChangeListener { _, _ -> updateUi() }
|
||||||
binding.editVolumeName.setOnEditorActionListener { _, _, _ -> onPathSelected(); true }
|
binding.editVolumeName.setOnEditorActionListener { _, _, _ ->
|
||||||
|
if (binding.editVolumeName.text.isEmpty()) {
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
if (binding.switchHiddenVolume.isChecked) R.string.empty_volume_name else R.string.empty_volume_path,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
onPathSelected()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
binding.buttonAction.setOnClickListener { onPathSelected() }
|
binding.buttonAction.setOnClickListener { onPathSelected() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||||
super.onViewStateRestored(savedInstanceState)
|
super.onViewStateRestored(savedInstanceState)
|
||||||
(activity as AddVolumeActivity).onFragmentLoaded(true)
|
(activity as AddVolumeActivity).onFragmentLoaded(true)
|
||||||
showRightSection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun launchPickDirectory() {
|
private fun launchPickDirectory() {
|
||||||
@ -160,70 +185,82 @@ class SelectPathFragment: Fragment() {
|
|||||||
PathUtils.safePickDirectory(pickDirectory, requireContext(), theme)
|
PathUtils.safePickDirectory(pickDirectory, requireContext(), theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showRightSection() {
|
private fun updateUi(volumeName: CharSequence = binding.editVolumeName.text) {
|
||||||
if (binding.switchHiddenVolume.isChecked) {
|
var warning = -1
|
||||||
binding.textLabel.text = requireContext().getString(R.string.volume_name_label)
|
fun updateWarning() {
|
||||||
binding.editVolumeName.hint = requireContext().getString(R.string.volume_name_hint)
|
if (warning == -1) {
|
||||||
binding.buttonPickDirectory.visibility = View.GONE
|
binding.textWarning.isVisible = false
|
||||||
} else {
|
|
||||||
binding.textLabel.text = requireContext().getString(R.string.volume_path_label)
|
|
||||||
binding.editVolumeName.hint = requireContext().getString(R.string.volume_path_hint)
|
|
||||||
binding.buttonPickDirectory.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshButtonText() {
|
|
||||||
binding.buttonAction.text = getString(
|
|
||||||
if (pickMode || volumeAction == Action.ADD) {
|
|
||||||
if (binding.switchRemember.isChecked || currentVolumeData != null) {
|
|
||||||
R.string.add_volume
|
|
||||||
} else {
|
|
||||||
R.string.open_volume
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
R.string.create_volume
|
binding.textWarning.isVisible = true
|
||||||
|
binding.textWarning.text = getString(warning)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshStatus(content: CharSequence) {
|
val hidden = binding.switchHiddenVolume.isChecked
|
||||||
|
binding.editVolumeName.isVisible = hidden || inputViewModel.showEditText
|
||||||
|
binding.buttonPickDirectory.isVisible = !hidden
|
||||||
|
binding.textOr.isVisible = !hidden && !inputViewModel.showEditText
|
||||||
|
binding.buttonEnterPath.isVisible = !hidden && !inputViewModel.showEditText
|
||||||
|
if (hidden) {
|
||||||
|
binding.textLabel.text = getString(R.string.volume_name_label)
|
||||||
|
binding.editVolumeName.hint = getString(R.string.volume_name_hint)
|
||||||
|
} else {
|
||||||
|
binding.textLabel.text = getString(R.string.volume_path_label)
|
||||||
|
binding.editVolumeName.hint = getString(R.string.volume_path_hint)
|
||||||
|
}
|
||||||
|
if (hidden && volumeName.contains(PathUtils.SEPARATOR)) {
|
||||||
|
warning = R.string.error_slash_in_name
|
||||||
|
}
|
||||||
|
// exit early if possible to avoid filesystem queries
|
||||||
|
if (volumeName.isEmpty() || warning != -1 || (!hidden && !inputViewModel.showEditText)) {
|
||||||
|
binding.buttonAction.isVisible = false
|
||||||
|
binding.switchRemember.isVisible = false
|
||||||
|
updateWarning()
|
||||||
|
return
|
||||||
|
}
|
||||||
val path = File(getCurrentVolumePath())
|
val path = File(getCurrentVolumePath())
|
||||||
volumeAction = if (path.isDirectory) {
|
volumeAction = if (path.isDirectory) {
|
||||||
if (path.list()?.isEmpty() == true || content.isEmpty()) Action.CREATE else Action.ADD
|
if (path.list()?.isEmpty() == true) {
|
||||||
|
Action.CREATE
|
||||||
|
} else if (pickMode || !binding.switchRemember.isChecked) {
|
||||||
|
Action.OPEN
|
||||||
|
} else {
|
||||||
|
Action.ADD
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Action.CREATE
|
Action.CREATE
|
||||||
}
|
}
|
||||||
currentVolumeData = if (volumeAction == Action.CREATE) {
|
val valid = !(volumeAction == Action.CREATE && pickMode)
|
||||||
null
|
binding.switchRemember.isVisible = valid
|
||||||
} else {
|
binding.buttonAction.isVisible = valid
|
||||||
volumeDatabase.getVolume(content.toString(), binding.switchHiddenVolume.isChecked)
|
if (valid) {
|
||||||
}
|
binding.buttonAction.text = getString(volumeAction!!.getStringResId())
|
||||||
binding.textWarning.visibility = if (volumeAction == Action.CREATE && pickMode) {
|
currentVolumeData = if (volumeAction == Action.CREATE) {
|
||||||
binding.textWarning.text = getString(R.string.choose_existing_volume)
|
null
|
||||||
binding.buttonAction.isEnabled = false
|
|
||||||
View.VISIBLE
|
|
||||||
} else {
|
|
||||||
refreshButtonText()
|
|
||||||
binding.buttonAction.isEnabled = true
|
|
||||||
if (currentVolumeData == null) {
|
|
||||||
View.GONE
|
|
||||||
} else {
|
} else {
|
||||||
binding.textWarning.text = getString(R.string.volume_alread_saved)
|
volumeDatabase.getVolume(volumeName.toString(), hidden)
|
||||||
View.VISIBLE
|
|
||||||
}
|
}
|
||||||
|
if (currentVolumeData != null) {
|
||||||
|
warning = R.string.volume_alread_saved
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warning = R.string.choose_existing_volume
|
||||||
}
|
}
|
||||||
|
updateWarning()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onDirectoryPicked(uri: Uri) {
|
private fun onDirectoryPicked(uri: Uri) {
|
||||||
val path = PathUtils.getFullPathFromTreeUri(uri, requireContext())
|
val path = PathUtils.getFullPathFromTreeUri(uri, requireContext())
|
||||||
if (path != null)
|
if (path == null) {
|
||||||
binding.editVolumeName.setText(path)
|
|
||||||
else
|
|
||||||
CustomAlertDialogBuilder(requireContext(), theme)
|
CustomAlertDialogBuilder(requireContext(), theme)
|
||||||
.setTitle(R.string.error)
|
.setTitle(R.string.error)
|
||||||
.setMessage(R.string.path_error)
|
.setMessage(R.string.path_error)
|
||||||
.setPositiveButton(R.string.ok, null)
|
.setPositiveButton(R.string.ok, null)
|
||||||
.show()
|
.show()
|
||||||
|
} else {
|
||||||
|
inputViewModel.showEditText = true
|
||||||
|
binding.editVolumeName.setText(path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCurrentVolumePath(): String {
|
private fun getCurrentVolumePath(): String {
|
||||||
@ -243,13 +280,7 @@ class SelectPathFragment: Fragment() {
|
|||||||
if (currentVolumeData == null) { // volume not known
|
if (currentVolumeData == null) { // volume not known
|
||||||
val currentVolumeValue = binding.editVolumeName.text.toString()
|
val currentVolumeValue = binding.editVolumeName.text.toString()
|
||||||
val isHidden = binding.switchHiddenVolume.isChecked
|
val isHidden = binding.switchHiddenVolume.isChecked
|
||||||
if (currentVolumeValue.isEmpty()) {
|
if (isHidden && currentVolumeValue.contains(PathUtils.SEPARATOR)) {
|
||||||
Toast.makeText(
|
|
||||||
requireContext(),
|
|
||||||
if (isHidden) R.string.enter_volume_name else R.string.enter_volume_path,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
} else if (isHidden && currentVolumeValue.contains(PathUtils.SEPARATOR)) {
|
|
||||||
Toast.makeText(requireContext(), R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.error_slash_in_name, Toast.LENGTH_SHORT).show()
|
||||||
} else if (isHidden && volumeAction == Action.CREATE) {
|
} else if (isHidden && volumeAction == Action.CREATE) {
|
||||||
CustomAlertDialogBuilder(requireContext(), theme)
|
CustomAlertDialogBuilder(requireContext(), theme)
|
||||||
@ -263,71 +294,83 @@ class SelectPathFragment: Fragment() {
|
|||||||
onNewVolumeSelected(currentVolumeValue, isHidden)
|
onNewVolumeSelected(currentVolumeValue, isHidden)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(activity as AddVolumeActivity).onVolumeSelected(currentVolumeData!!, true)
|
with (activity as AddVolumeActivity) {
|
||||||
|
if (volumeAction!! == Action.OPEN) {
|
||||||
|
openVolume(currentVolumeData!!, true)
|
||||||
|
} else {
|
||||||
|
onVolumeAdded()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onNewVolumeSelected(currentVolumeValue: String, isHidden: Boolean) {
|
private fun onNewVolumeSelected(currentVolumeValue: String, isHidden: Boolean) {
|
||||||
val volumePath = getCurrentVolumePath()
|
val volumePath = getCurrentVolumePath()
|
||||||
when (volumeAction!!) {
|
if (volumeAction!! == Action.CREATE) {
|
||||||
Action.CREATE -> {
|
val volumeFile = File(volumePath)
|
||||||
val volumeFile = File(volumePath)
|
var goodDirectory = false
|
||||||
var goodDirectory = false
|
if (volumeFile.isFile) {
|
||||||
if (volumeFile.isFile) {
|
Toast.makeText(requireContext(), R.string.error_is_file, Toast.LENGTH_SHORT).show()
|
||||||
Toast.makeText(requireContext(), R.string.error_is_file, Toast.LENGTH_SHORT).show()
|
} else if (volumeFile.isDirectory) {
|
||||||
} else if (volumeFile.isDirectory) {
|
val dirContent = volumeFile.list()
|
||||||
val dirContent = volumeFile.list()
|
if (dirContent != null) {
|
||||||
if (dirContent != null) {
|
if (dirContent.isEmpty()) {
|
||||||
if (dirContent.isEmpty()) {
|
if (volumeFile.canWrite()) {
|
||||||
if (volumeFile.canWrite()) {
|
goodDirectory = true
|
||||||
goodDirectory = true
|
|
||||||
} else {
|
|
||||||
errorDirectoryNotWritable(volumePath)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(requireContext(), R.string.dir_not_empty, Toast.LENGTH_SHORT).show()
|
errorDirectoryNotWritable(volumePath)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(requireContext(), R.string.listdir_null_error_msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.dir_not_empty, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (File(PathUtils.getParentPath(volumePath)).canWrite()) {
|
Toast.makeText(
|
||||||
goodDirectory = true
|
requireContext(),
|
||||||
} else {
|
R.string.listdir_null_error_msg,
|
||||||
errorDirectoryNotWritable(volumePath)
|
Toast.LENGTH_SHORT
|
||||||
}
|
).show()
|
||||||
}
|
}
|
||||||
if (goodDirectory) {
|
} else {
|
||||||
(activity as AddVolumeActivity).createVolume(volumePath, isHidden, binding.switchRemember.isChecked)
|
if (File(PathUtils.getParentPath(volumePath)).canWrite()) {
|
||||||
|
goodDirectory = true
|
||||||
|
} else {
|
||||||
|
errorDirectoryNotWritable(volumePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action.ADD -> {
|
if (goodDirectory) {
|
||||||
val volumeType = EncryptedVolume.getVolumeType(volumePath)
|
(activity as AddVolumeActivity).createVolume(
|
||||||
if (volumeType < 0) {
|
volumePath,
|
||||||
CustomAlertDialogBuilder(requireContext(), theme)
|
isHidden,
|
||||||
.setTitle(R.string.error)
|
binding.switchRemember.isChecked
|
||||||
.setMessage(R.string.error_not_a_volume)
|
)
|
||||||
.setPositiveButton(R.string.ok, null)
|
}
|
||||||
.show()
|
} else {
|
||||||
} else if (!File(volumePath).canWrite()) {
|
val volumeType = EncryptedVolume.getVolumeType(volumePath)
|
||||||
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
|
if (volumeType < 0) {
|
||||||
.setTitle(R.string.warning)
|
CustomAlertDialogBuilder(requireContext(), theme)
|
||||||
.setCancelable(false)
|
.setTitle(R.string.error)
|
||||||
.setPositiveButton(R.string.ok) { _, _ -> addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
|
.setMessage(R.string.error_not_a_volume)
|
||||||
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext())) {
|
.setPositiveButton(R.string.ok, null)
|
||||||
dialog.setView(
|
.show()
|
||||||
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
|
} else if (!File(volumePath).canWrite()) {
|
||||||
path.text = PathUtils.getPackageDataFolder(requireContext())
|
val dialog = CustomAlertDialogBuilder(requireContext(), theme)
|
||||||
footer.text = getString(R.string.sdcard_error_add_footer)
|
.setTitle(R.string.warning)
|
||||||
}.root
|
.setCancelable(false)
|
||||||
)
|
.setPositiveButton(R.string.ok) { _, _ -> onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType) }
|
||||||
} else {
|
if (PathUtils.isPathOnExternalStorage(volumePath, requireContext())) {
|
||||||
dialog.setMessage(R.string.add_cant_write_warning)
|
dialog.setView(
|
||||||
}
|
DialogSdcardErrorBinding.inflate(layoutInflater).apply {
|
||||||
dialog.show()
|
path.text = PathUtils.getPackageDataFolder(requireContext())
|
||||||
|
footer.text = getString(R.string.sdcard_error_add_footer)
|
||||||
|
}.root
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
addVolume(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
|
dialog.setMessage(R.string.add_cant_write_warning)
|
||||||
}
|
}
|
||||||
|
dialog.show()
|
||||||
|
} else {
|
||||||
|
onExistingVolumeSelected(if (isHidden) currentVolumeValue else volumePath, isHidden, volumeType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -349,11 +392,17 @@ class SelectPathFragment: Fragment() {
|
|||||||
dialog.show()
|
dialog.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addVolume(volumeName: String, isHidden: Boolean, volumeType: Byte) {
|
private fun onExistingVolumeSelected(volumeName: String, isHidden: Boolean, volumeType: Byte) {
|
||||||
val volumeData = VolumeData(volumeName, isHidden, volumeType)
|
val volumeData = VolumeData(VolumeData.newUuid(), volumeName, isHidden, volumeType)
|
||||||
if (binding.switchRemember.isChecked) {
|
if (binding.switchRemember.isChecked) {
|
||||||
volumeDatabase.saveVolume(volumeData)
|
volumeDatabase.saveVolume(volumeData)
|
||||||
}
|
}
|
||||||
(activity as AddVolumeActivity).onVolumeSelected(volumeData, binding.switchRemember.isChecked)
|
with (activity as AddVolumeActivity) {
|
||||||
|
if (volumeAction!! == Action.OPEN) {
|
||||||
|
openVolume(volumeData, binding.switchRemember.isChecked)
|
||||||
|
} else {
|
||||||
|
onVolumeAdded()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,68 +0,0 @@
|
|||||||
package sushi.hardcore.droidfs.content_providers
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import sushi.hardcore.droidfs.BuildConfig
|
|
||||||
import sushi.hardcore.droidfs.util.Wiper
|
|
||||||
import java.io.File
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class DiskFileProvider: TemporaryFileProvider<File>() {
|
|
||||||
companion object {
|
|
||||||
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".disk_provider"
|
|
||||||
private val CONTENT_URI: Uri = Uri.parse("content://$AUTHORITY")
|
|
||||||
const val TEMPORARY_FILES_DIR_NAME = "temp"
|
|
||||||
|
|
||||||
private lateinit var tempFilesDir: File
|
|
||||||
|
|
||||||
private var files = HashMap<Uri, TemporaryFileProvider<File>.SharedFile>()
|
|
||||||
|
|
||||||
fun wipe() {
|
|
||||||
for (i in files.values) {
|
|
||||||
Wiper.wipe(i.file)
|
|
||||||
}
|
|
||||||
files.clear()
|
|
||||||
tempFilesDir.listFiles()?.let {
|
|
||||||
for (file in it) {
|
|
||||||
Wiper.wipe(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(): Boolean {
|
|
||||||
context?.let {
|
|
||||||
tempFilesDir = File(it.cacheDir, TEMPORARY_FILES_DIR_NAME)
|
|
||||||
return tempFilesDir.mkdirs()
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getFile(uri: Uri): SharedFile? = files[uri]
|
|
||||||
|
|
||||||
override fun newFile(name: String, size: Long): Uri? {
|
|
||||||
val uuid = UUID.randomUUID().toString()
|
|
||||||
val file = File(tempFilesDir, uuid)
|
|
||||||
return if (file.createNewFile()) {
|
|
||||||
Uri.withAppendedPath(CONTENT_URI, uuid).also {
|
|
||||||
files[it] = SharedFile(name, size, file)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun delete(uri: Uri, givenSelection: String?, givenSelectionArgs: Array<String>?): Int {
|
|
||||||
return if (files.remove(uri)?.file?.also { Wiper.wipe(it) } == null) 0 else 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
|
||||||
return if (("w" in mode && callingPackage == BuildConfig.APPLICATION_ID) || "w" !in mode) {
|
|
||||||
files[uri]?.file?.let {
|
|
||||||
return ParcelFileDescriptor.open(it, ParcelFileDescriptor.parseMode(mode))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw SecurityException("Read-only access")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
package sushi.hardcore.droidfs.content_providers
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import sushi.hardcore.droidfs.BuildConfig
|
|
||||||
import sushi.hardcore.droidfs.MemFile
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class MemoryFileProvider: TemporaryFileProvider<MemFile>() {
|
|
||||||
companion object {
|
|
||||||
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".memory_provider"
|
|
||||||
private val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")
|
|
||||||
|
|
||||||
private var files = HashMap<Uri, TemporaryFileProvider<MemFile>.SharedFile>()
|
|
||||||
|
|
||||||
fun wipe() {
|
|
||||||
for (i in files.values) {
|
|
||||||
i.file.close()
|
|
||||||
}
|
|
||||||
files.clear()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(): Boolean = true
|
|
||||||
|
|
||||||
override fun getFile(uri: Uri): SharedFile? = files[uri]
|
|
||||||
|
|
||||||
override fun newFile(name: String, size: Long): Uri? {
|
|
||||||
val uuid = UUID.randomUUID().toString()
|
|
||||||
return Uri.withAppendedPath(BASE_URI, uuid).also {
|
|
||||||
files[it] = SharedFile(name, size, MemFile.create(uuid, size) ?: return null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
|
||||||
return if (files.remove(uri)?.file?.close() == null) 0 else 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
|
||||||
return files[uri]?.file?.getParcelFileDescriptor()?.also {
|
|
||||||
FileInputStream(it.fileDescriptor).apply {
|
|
||||||
channel.position(0)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,23 +2,87 @@ package sushi.hardcore.droidfs.content_providers
|
|||||||
|
|
||||||
import android.content.ContentProvider
|
import android.content.ContentProvider
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
|
import android.content.Intent
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.database.MatrixCursor
|
import android.database.MatrixCursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
|
import android.util.Log
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import sushi.hardcore.droidfs.BuildConfig
|
||||||
|
import sushi.hardcore.droidfs.EncryptedFileProvider
|
||||||
|
import sushi.hardcore.droidfs.VolumeManager
|
||||||
|
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||||
|
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||||
|
import sushi.hardcore.droidfs.util.Wiper
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
abstract class TemporaryFileProvider<T>: ContentProvider() {
|
class TemporaryFileProvider : ContentProvider() {
|
||||||
protected inner class SharedFile(val name: String, val size: Long, val file: T)
|
private inner class ProvidedFile(
|
||||||
|
val file: EncryptedFileProvider.ExportedFile,
|
||||||
|
val size: Long,
|
||||||
|
val volumeId: Int
|
||||||
|
)
|
||||||
|
|
||||||
protected abstract fun getFile(uri: Uri): SharedFile?
|
companion object {
|
||||||
abstract fun newFile(name: String, size: Long): Uri?
|
private const val TAG = "TemporaryFileProvider"
|
||||||
|
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".temporary_provider"
|
||||||
|
private val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")
|
||||||
|
|
||||||
override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
|
lateinit var instance: TemporaryFileProvider
|
||||||
val file = getFile(uri) ?: return null
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
private val usfSafWriteDelegate = AndroidUtils.LiveBooleanPreference("usf_saf_write", false)
|
||||||
|
private val usfSafWrite by usfSafWriteDelegate
|
||||||
|
private lateinit var volumeManager: VolumeManager
|
||||||
|
lateinit var encryptedFileProvider: EncryptedFileProvider
|
||||||
|
private val files = HashMap<Uri, ProvidedFile>()
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean {
|
||||||
|
return context?.let {
|
||||||
|
volumeManager = (it.applicationContext as VolumeManagerApp).volumeManager
|
||||||
|
usfSafWriteDelegate.init(it)
|
||||||
|
encryptedFileProvider = EncryptedFileProvider(it)
|
||||||
|
instance = this
|
||||||
|
val tmpFilesDir = EncryptedFileProvider.getTmpFilesDir(it)
|
||||||
|
val success = tmpFilesDir.mkdirs()
|
||||||
|
// wipe any additional files not previously deleted
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
tmpFilesDir.listFiles()?.onEach { f -> Wiper.wipe(f) }
|
||||||
|
}
|
||||||
|
success
|
||||||
|
} ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportFile(
|
||||||
|
exportedFile: EncryptedFileProvider.ExportedFile,
|
||||||
|
size: Long,
|
||||||
|
volumeId: Int
|
||||||
|
): Uri? {
|
||||||
|
if (!encryptedFileProvider.exportFile(exportedFile, volumeManager.getVolume(volumeId)!!)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return Uri.withAppendedPath(BASE_URI, UUID.randomUUID().toString()).also {
|
||||||
|
files[it] = ProvidedFile(exportedFile, size, volumeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query(
|
||||||
|
uri: Uri,
|
||||||
|
projection: Array<String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<String>?,
|
||||||
|
sortOrder: String?
|
||||||
|
): Cursor? {
|
||||||
|
val file = files[uri] ?: return null
|
||||||
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), 1).apply {
|
return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), 1).apply {
|
||||||
addRow(arrayOf(file.name, file.size))
|
addRow(arrayOf(File(file.file.path).name, file.size))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,11 +90,58 @@ abstract class TemporaryFileProvider<T>: ContentProvider() {
|
|||||||
throw UnsupportedOperationException("Operation not supported")
|
throw UnsupportedOperationException("Operation not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int {
|
override fun update(
|
||||||
|
uri: Uri,
|
||||||
|
values: ContentValues?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<String>?
|
||||||
|
): Int {
|
||||||
throw UnsupportedOperationException("Operation not supported")
|
throw UnsupportedOperationException("Operation not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getType(uri: Uri): String = getFile(uri)?.name?.let {
|
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)
|
MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(it).extension)
|
||||||
} ?: "application/octet-stream"
|
} ?: "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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -0,0 +1,294 @@
|
|||||||
|
package sushi.hardcore.droidfs.content_providers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.MatrixCursor
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.DocumentsProvider
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import sushi.hardcore.droidfs.BuildConfig
|
||||||
|
import sushi.hardcore.droidfs.EncryptedFileProvider
|
||||||
|
import sushi.hardcore.droidfs.R
|
||||||
|
import sushi.hardcore.droidfs.VolumeData
|
||||||
|
import sushi.hardcore.droidfs.VolumeManager
|
||||||
|
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||||
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
|
import sushi.hardcore.droidfs.filesystems.Stat
|
||||||
|
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||||
|
import sushi.hardcore.droidfs.util.PathUtils
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class VolumeProvider: DocumentsProvider() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DocumentsProvider"
|
||||||
|
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".volume_provider"
|
||||||
|
private val DEFAULT_ROOT_PROJECTION = arrayOf(
|
||||||
|
DocumentsContract.Root.COLUMN_ROOT_ID,
|
||||||
|
DocumentsContract.Root.COLUMN_FLAGS,
|
||||||
|
DocumentsContract.Root.COLUMN_ICON,
|
||||||
|
DocumentsContract.Root.COLUMN_TITLE,
|
||||||
|
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
|
||||||
|
)
|
||||||
|
private val DEFAULT_DOCUMENT_PROJECTION = arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||||
|
DocumentsContract.Document.COLUMN_FLAGS,
|
||||||
|
DocumentsContract.Document.COLUMN_SIZE,
|
||||||
|
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun notifyRootsChanged(context: Context) {
|
||||||
|
context.contentResolver.notifyChange(DocumentsContract.buildRootsUri(AUTHORITY), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val usfExposeDelegate = AndroidUtils.LiveBooleanPreference("usf_expose", false)
|
||||||
|
private val usfExpose by usfExposeDelegate
|
||||||
|
private val usfSafWriteDelegate = AndroidUtils.LiveBooleanPreference("usf_saf_write", false)
|
||||||
|
private val usfSafWrite by usfSafWriteDelegate
|
||||||
|
private lateinit var volumeManager: VolumeManager
|
||||||
|
private val volumes = HashMap<String, Pair<Int, VolumeData>>()
|
||||||
|
private lateinit var encryptedFileProvider: EncryptedFileProvider
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean {
|
||||||
|
val context = (context ?: return false)
|
||||||
|
AndroidUtils.LiveBooleanPreference.init(context, usfExposeDelegate, usfSafWriteDelegate)
|
||||||
|
volumeManager = (context.applicationContext as VolumeManagerApp).volumeManager
|
||||||
|
encryptedFileProvider = EncryptedFileProvider(context)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||||
|
val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
|
||||||
|
if (!usfExpose) return cursor
|
||||||
|
volumes.clear()
|
||||||
|
for (volume in volumeManager.listVolumes()) {
|
||||||
|
var flags = DocumentsContract.Root.FLAG_LOCAL_ONLY or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
|
||||||
|
if (usfSafWrite && volume.second.canWrite(context!!.filesDir.path)) {
|
||||||
|
flags = flags or DocumentsContract.Root.FLAG_SUPPORTS_CREATE
|
||||||
|
}
|
||||||
|
cursor.newRow().apply {
|
||||||
|
add(DocumentsContract.Root.COLUMN_ROOT_ID, volume.second.name)
|
||||||
|
add(DocumentsContract.Root.COLUMN_FLAGS, flags)
|
||||||
|
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.icon_document_provider)
|
||||||
|
add(DocumentsContract.Root.COLUMN_TITLE, volume.second.name)
|
||||||
|
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, volume.second.uuid)
|
||||||
|
}
|
||||||
|
volumes[volume.second.uuid] = volume
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class DocumentData(
|
||||||
|
val rootId: String,
|
||||||
|
val volumeId: Int,
|
||||||
|
val volumeData: VolumeData,
|
||||||
|
val encryptedVolume: EncryptedVolume,
|
||||||
|
val path: String
|
||||||
|
) {
|
||||||
|
fun child(childPath: String) = DocumentData(rootId, volumeId, volumeData, encryptedVolume, childPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDocumentId(documentId: String): DocumentData? {
|
||||||
|
val splits = documentId.split("/", limit = 2)
|
||||||
|
if (splits.size > 2) {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
volumes[splits[0]]?.let {
|
||||||
|
val encryptedVolume = volumeManager.getVolume(it.first) ?: return null
|
||||||
|
val path = "/"+if (splits.size == 2) {
|
||||||
|
splits[1]
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
return DocumentData(splits[0], it.first, it.second, encryptedVolume, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean {
|
||||||
|
if (!usfExpose) return false
|
||||||
|
val parent = parseDocumentId(parentDocumentId) ?: return false
|
||||||
|
val child = parseDocumentId(documentId) ?: return false
|
||||||
|
return parent.rootId == child.rootId && PathUtils.isChildOf(child.path, parent.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addDocumentRow(cursor: MatrixCursor, volumeData: VolumeData, documentId: String, name: String, stat: Stat) {
|
||||||
|
val isDirectory = stat.type == Stat.S_IFDIR
|
||||||
|
var flags = 0
|
||||||
|
if (usfSafWrite && volumeData.canWrite(context!!.filesDir.path)) {
|
||||||
|
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE or DocumentsContract.Document.FLAG_SUPPORTS_RENAME
|
||||||
|
if (isDirectory) {
|
||||||
|
flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
|
||||||
|
} else if (stat.type == Stat.S_IFREG) {
|
||||||
|
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mimeType = if (isDirectory) {
|
||||||
|
DocumentsContract.Document.MIME_TYPE_DIR
|
||||||
|
} else {
|
||||||
|
MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(name).extension)
|
||||||
|
?: "application/octet-stream"
|
||||||
|
}
|
||||||
|
cursor.newRow().apply {
|
||||||
|
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId)
|
||||||
|
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, name)
|
||||||
|
add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType)
|
||||||
|
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
|
||||||
|
add(DocumentsContract.Document.COLUMN_SIZE, stat.size)
|
||||||
|
add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, stat.mTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryDocument(documentId: String, projection: Array<out String>?): Cursor {
|
||||||
|
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
|
||||||
|
if (!usfExpose) return cursor
|
||||||
|
val document = parseDocumentId(documentId) ?: return cursor
|
||||||
|
document.encryptedVolume.getAttr(document.path)?.let { stat ->
|
||||||
|
val name = if (document.path == "/") {
|
||||||
|
document.volumeData.shortName
|
||||||
|
} else {
|
||||||
|
File(document.path).name
|
||||||
|
}
|
||||||
|
addDocumentRow(cursor, document.volumeData, documentId, name, stat)
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryChildDocuments(
|
||||||
|
parentDocumentId: String,
|
||||||
|
projection: Array<out String>?,
|
||||||
|
sortOrder: String?
|
||||||
|
): Cursor {
|
||||||
|
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
|
||||||
|
if (!usfExpose) return cursor
|
||||||
|
val document = parseDocumentId(parentDocumentId) ?: return cursor
|
||||||
|
document.encryptedVolume.readDir(document.path)?.let { content ->
|
||||||
|
for (i in content) {
|
||||||
|
if (i.isParentFolder) continue
|
||||||
|
addDocumentRow(cursor, document.volumeData, document.rootId+i.fullPath, i.name, i.stat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
class LazyExportedFile(
|
||||||
|
private val encryptedFileProvider: EncryptedFileProvider,
|
||||||
|
private val encryptedVolume: EncryptedVolume,
|
||||||
|
path: String,
|
||||||
|
) : EncryptedFileProvider.ExportedFile(path) {
|
||||||
|
|
||||||
|
private val exportedFile: EncryptedFileProvider.ExportedFile by lazy {
|
||||||
|
val size = encryptedVolume.getAttr(path)?.size ?: run {
|
||||||
|
Log.e(TAG, "stat() failed")
|
||||||
|
throw RuntimeException("stat() failed")
|
||||||
|
}
|
||||||
|
val exportedFile = encryptedFileProvider.createFile(path, size) ?: run {
|
||||||
|
Log.e(TAG, "Can't create exported file")
|
||||||
|
throw RuntimeException("Can't create exported file")
|
||||||
|
}
|
||||||
|
if (!encryptedFileProvider.exportFile(exportedFile, encryptedVolume)) {
|
||||||
|
Log.e(TAG, "File export failed")
|
||||||
|
throw RuntimeException("File export failed")
|
||||||
|
}
|
||||||
|
exportedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun open(mode: Int, furtive: Boolean) = exportedFile.open(mode, furtive)
|
||||||
|
override fun free() = exportedFile.free()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openDocument(
|
||||||
|
documentId: String,
|
||||||
|
mode: String,
|
||||||
|
signal: CancellationSignal?
|
||||||
|
): ParcelFileDescriptor? {
|
||||||
|
if (!usfExpose) return null
|
||||||
|
val document = parseDocumentId(documentId) ?: return null
|
||||||
|
|
||||||
|
val lazyExportedFile = LazyExportedFile(encryptedFileProvider, document.encryptedVolume, document.path)
|
||||||
|
|
||||||
|
val result = encryptedFileProvider.openFile(
|
||||||
|
lazyExportedFile,
|
||||||
|
mode,
|
||||||
|
document.encryptedVolume,
|
||||||
|
volumeManager.getCoroutineScope(document.volumeId),
|
||||||
|
true,
|
||||||
|
usfSafWrite,
|
||||||
|
)
|
||||||
|
when (result.second) {
|
||||||
|
EncryptedFileProvider.Error.SUCCESS -> return result.first!!
|
||||||
|
EncryptedFileProvider.Error.WRITE_ACCESS_DENIED -> Log.e(TAG, "Unauthorized write access requested from $callingPackage")
|
||||||
|
else -> result.second.log()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createDocument(
|
||||||
|
parentDocumentId: String,
|
||||||
|
mimeType: String?,
|
||||||
|
displayName: String
|
||||||
|
): String? {
|
||||||
|
if (!usfExpose || !usfSafWrite) return null
|
||||||
|
val document = parseDocumentId(parentDocumentId) ?: return null
|
||||||
|
val path = PathUtils.pathJoin(document.path, displayName)
|
||||||
|
var success = false
|
||||||
|
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||||
|
success = document.encryptedVolume.mkdir(path)
|
||||||
|
} else {
|
||||||
|
val f = document.encryptedVolume.openFileWriteMode(path)
|
||||||
|
if (f != -1L) {
|
||||||
|
document.encryptedVolume.closeFile(f)
|
||||||
|
success = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (success) {
|
||||||
|
document.rootId+path
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteDocument(documentId: String) {
|
||||||
|
if (!usfExpose || !usfSafWrite) return
|
||||||
|
|
||||||
|
fun recursiveRemoveDirectory(document: DocumentData) {
|
||||||
|
document.encryptedVolume.readDir(document.path)?.forEach { e ->
|
||||||
|
val childPath = PathUtils.pathJoin(document.path, e.name)
|
||||||
|
if (e.isDirectory) {
|
||||||
|
recursiveRemoveDirectory(document.child(childPath))
|
||||||
|
} else {
|
||||||
|
document.encryptedVolume.deleteFile(childPath)
|
||||||
|
}
|
||||||
|
revokeDocumentPermission(document.rootId+childPath)
|
||||||
|
}
|
||||||
|
document.encryptedVolume.rmdir(document.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
val document = parseDocumentId(documentId) ?: return
|
||||||
|
document.encryptedVolume.getAttr(document.path)?.let { stat ->
|
||||||
|
if (stat.type == Stat.S_IFDIR) {
|
||||||
|
recursiveRemoveDirectory(document)
|
||||||
|
} else {
|
||||||
|
document.encryptedVolume.deleteFile(document.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun renameDocument(documentId: String, displayName: String): String {
|
||||||
|
if (!usfExpose || !usfSafWrite) return documentId
|
||||||
|
val document = parseDocumentId(documentId) ?: return documentId
|
||||||
|
val newPath = PathUtils.pathJoin(PathUtils.getParentPath(document.path), displayName)
|
||||||
|
return if (document.encryptedVolume.rename(document.path, newPath)) {
|
||||||
|
document.rootId+newPath
|
||||||
|
} else {
|
||||||
|
documentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,20 +1,17 @@
|
|||||||
package sushi.hardcore.droidfs.explorers
|
package sushi.hardcore.droidfs.explorers
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.addCallback
|
import androidx.activity.addCallback
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.view.isVisible
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@ -23,12 +20,15 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.yield
|
||||||
import sushi.hardcore.droidfs.BaseActivity
|
import sushi.hardcore.droidfs.BaseActivity
|
||||||
import sushi.hardcore.droidfs.Constants
|
import sushi.hardcore.droidfs.Constants
|
||||||
|
import sushi.hardcore.droidfs.EncryptedFileProvider
|
||||||
import sushi.hardcore.droidfs.FileShare
|
import sushi.hardcore.droidfs.FileShare
|
||||||
import sushi.hardcore.droidfs.FileTypes
|
import sushi.hardcore.droidfs.FileTypes
|
||||||
import sushi.hardcore.droidfs.LoadingTask
|
import sushi.hardcore.droidfs.LoadingTask
|
||||||
@ -36,8 +36,7 @@ import sushi.hardcore.droidfs.R
|
|||||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||||
import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter
|
import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter
|
||||||
import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
|
import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
|
||||||
import sushi.hardcore.droidfs.content_providers.DiskFileProvider
|
import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
|
||||||
import sushi.hardcore.droidfs.content_providers.MemoryFileProvider
|
|
||||||
import sushi.hardcore.droidfs.file_operations.FileOperationService
|
import sushi.hardcore.droidfs.file_operations.FileOperationService
|
||||||
import sushi.hardcore.droidfs.file_operations.OperationFile
|
import sushi.hardcore.droidfs.file_operations.OperationFile
|
||||||
import sushi.hardcore.droidfs.file_operations.TaskResult
|
import sushi.hardcore.droidfs.file_operations.TaskResult
|
||||||
@ -49,6 +48,8 @@ import sushi.hardcore.droidfs.file_viewers.VideoPlayer
|
|||||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
import sushi.hardcore.droidfs.filesystems.Stat
|
import sushi.hardcore.droidfs.filesystems.Stat
|
||||||
import sushi.hardcore.droidfs.util.PathUtils
|
import sushi.hardcore.droidfs.util.PathUtils
|
||||||
|
import sushi.hardcore.droidfs.util.UIUtils
|
||||||
|
import sushi.hardcore.droidfs.util.finishOnClose
|
||||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
import sushi.hardcore.droidfs.widgets.EditTextDialog
|
||||||
|
|
||||||
@ -69,6 +70,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
}
|
}
|
||||||
protected lateinit var fileOperationService: FileOperationService
|
protected lateinit var fileOperationService: FileOperationService
|
||||||
protected val activityScope = MainScope()
|
protected val activityScope = MainScope()
|
||||||
|
private var directoryLoadingTask: Job? = null
|
||||||
protected lateinit var explorerElements: MutableList<ExplorerElement>
|
protected lateinit var explorerElements: MutableList<ExplorerElement>
|
||||||
protected lateinit var explorerAdapter: ExplorerElementAdapter
|
protected lateinit var explorerAdapter: ExplorerElementAdapter
|
||||||
protected lateinit var app: VolumeManagerApp
|
protected lateinit var app: VolumeManagerApp
|
||||||
@ -79,14 +81,13 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
private lateinit var titleText: TextView
|
private lateinit var titleText: TextView
|
||||||
private lateinit var recycler_view_explorer: RecyclerView
|
private lateinit var recycler_view_explorer: RecyclerView
|
||||||
private lateinit var refresher: SwipeRefreshLayout
|
private lateinit var refresher: SwipeRefreshLayout
|
||||||
|
private lateinit var loader: ProgressBar
|
||||||
private lateinit var textDirEmpty: TextView
|
private lateinit var textDirEmpty: TextView
|
||||||
private lateinit var currentPathText: TextView
|
private lateinit var currentPathText: TextView
|
||||||
private lateinit var numberOfFilesText: TextView
|
private lateinit var numberOfFilesText: TextView
|
||||||
private lateinit var numberOfFoldersText: TextView
|
private lateinit var numberOfFoldersText: TextView
|
||||||
private lateinit var totalSizeText: TextView
|
private lateinit var totalSizeText: TextView
|
||||||
protected val fileShare by lazy {
|
protected val fileShare by lazy { FileShare(this) }
|
||||||
FileShare(encryptedVolume, this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@ -94,7 +95,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
usf_open = sharedPrefs.getBoolean("usf_open", false)
|
usf_open = sharedPrefs.getBoolean("usf_open", false)
|
||||||
volumeName = intent.getStringExtra("volumeName") ?: ""
|
volumeName = intent.getStringExtra("volumeName") ?: ""
|
||||||
volumeId = intent.getIntExtra("volumeId", -1)
|
volumeId = intent.getIntExtra("volumeId", -1)
|
||||||
encryptedVolume = app.volumeManager.getVolume(volumeId)!!
|
encryptedVolume = app.volumeManager.getVolume(volumeId)!!.also { finishOnClose(it) }
|
||||||
sortOrderEntries = resources.getStringArray(R.array.sort_orders_entries)
|
sortOrderEntries = resources.getStringArray(R.array.sort_orders_entries)
|
||||||
sortOrderValues = resources.getStringArray(R.array.sort_orders_values)
|
sortOrderValues = resources.getStringArray(R.array.sort_orders_values)
|
||||||
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||||
@ -103,6 +104,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
init()
|
init()
|
||||||
recycler_view_explorer = findViewById(R.id.recycler_view_explorer)
|
recycler_view_explorer = findViewById(R.id.recycler_view_explorer)
|
||||||
refresher = findViewById(R.id.refresher)
|
refresher = findViewById(R.id.refresher)
|
||||||
|
loader = findViewById(R.id.loader)
|
||||||
textDirEmpty = findViewById(R.id.text_dir_empty)
|
textDirEmpty = findViewById(R.id.text_dir_empty)
|
||||||
currentPathText = findViewById(R.id.current_path_text)
|
currentPathText = findViewById(R.id.current_path_text)
|
||||||
numberOfFilesText = findViewById(R.id.number_of_files_text)
|
numberOfFilesText = findViewById(R.id.number_of_files_text)
|
||||||
@ -183,43 +185,53 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
setContentView(R.layout.activity_explorer)
|
setContentView(R.layout.activity_explorer)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun bindFileOperationService(){
|
protected open fun bindFileOperationService() {
|
||||||
Intent(this, FileOperationService::class.java).also {
|
FileOperationService.bind(this) {
|
||||||
bindService(it, object : ServiceConnection {
|
fileOperationService = it
|
||||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
|
||||||
val binder = service as FileOperationService.LocalBinder
|
|
||||||
fileOperationService = binder.getService()
|
|
||||||
}
|
|
||||||
override fun onServiceDisconnected(arg0: ComponentName) {}
|
|
||||||
}, Context.BIND_AUTO_CREATE)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startFileViewer(cls: Class<*>, filePath: String) {
|
private fun startFileViewer(cls: Class<*>, filePath: String) {
|
||||||
val intent = Intent(this, cls).apply {
|
val intent = Intent(this, cls).apply {
|
||||||
putExtra("path", filePath)
|
putExtra("path", filePath)
|
||||||
putExtra("volume", encryptedVolume)
|
putExtra("volumeId", volumeId)
|
||||||
putExtra("sortOrder", sortOrderValues[currentSortOrderIndex])
|
putExtra("sortOrder", sortOrderValues[currentSortOrderIndex])
|
||||||
}
|
}
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected fun onExportFailed(errorResId: Int) {
|
||||||
|
CustomAlertDialogBuilder(this, theme)
|
||||||
|
.setTitle(R.string.error)
|
||||||
|
.setMessage(getString(R.string.tmp_export_failed, getString(errorResId)))
|
||||||
|
.setPositiveButton(R.string.ok, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun openWithExternalApp(path: String, size: Long) {
|
private fun openWithExternalApp(path: String, size: Long) {
|
||||||
app.isStartingExternalApp = true
|
app.isExporting = true
|
||||||
object : LoadingTask<Intent?>(this, theme, R.string.loading_msg_export) {
|
val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
|
||||||
override suspend fun doTask(): Intent? {
|
if (exportedFile == null) {
|
||||||
return fileShare.openWith(path, size)
|
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) { openIntent ->
|
}.startTask(lifecycleScope) { (intent, error) ->
|
||||||
if (openIntent == null) {
|
if (intent == null) {
|
||||||
CustomAlertDialogBuilder(this, theme)
|
onExportFailed(error!!)
|
||||||
.setTitle(R.string.error)
|
|
||||||
.setMessage(getString(R.string.export_failed, path))
|
|
||||||
.setPositiveButton(R.string.ok, null)
|
|
||||||
.show()
|
|
||||||
} else {
|
} else {
|
||||||
startActivity(openIntent)
|
app.isStartingExternalApp = true
|
||||||
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
app.isExporting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,6 +257,27 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected fun createNewFile(callback: (Long) -> Unit) {
|
||||||
|
EditTextDialog(this, R.string.enter_file_name) {
|
||||||
|
if (it.isEmpty()) {
|
||||||
|
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
|
||||||
|
createNewFile(callback)
|
||||||
|
} else {
|
||||||
|
val filePath = PathUtils.pathJoin(currentDirectoryPath, it)
|
||||||
|
val handleID = encryptedVolume.openFileWriteMode(filePath)
|
||||||
|
if (handleID == -1L) {
|
||||||
|
CustomAlertDialogBuilder(this, theme)
|
||||||
|
.setTitle(R.string.error)
|
||||||
|
.setMessage(R.string.file_creation_failed)
|
||||||
|
.setPositiveButton(R.string.ok, null)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
callback(handleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun setVolumeNameTitle() {
|
private fun setVolumeNameTitle() {
|
||||||
titleText.text = getString(R.string.volume, volumeName)
|
titleText.text = getString(R.string.volume, volumeName)
|
||||||
}
|
}
|
||||||
@ -298,17 +331,15 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun displayExplorerElements() {
|
private fun displayExplorerElements() {
|
||||||
synchronized(this) {
|
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
|
||||||
ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
|
|
||||||
}
|
|
||||||
unselectAll(false)
|
unselectAll(false)
|
||||||
|
loader.isVisible = false
|
||||||
|
recycler_view_explorer.isVisible = true
|
||||||
explorerAdapter.explorerElements = explorerElements
|
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) {
|
for (child in encryptedVolume.readDir(directory.fullPath) ?: return) {
|
||||||
if (child.isDirectory) {
|
if (child.isDirectory) {
|
||||||
recursiveSetSize(child)
|
recursiveSetSize(child)
|
||||||
@ -332,15 +363,16 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) {
|
protected fun setCurrentPath(path: String, onDisplayed: (() -> Unit)? = null) = lifecycleScope.launch {
|
||||||
synchronized(this) {
|
directoryLoadingTask?.cancelAndJoin()
|
||||||
explorerElements = encryptedVolume.readDir(path) ?: return
|
recycler_view_explorer.isVisible = false
|
||||||
if (path != "/") {
|
loader.isVisible = true
|
||||||
explorerElements.add(
|
explorerElements = encryptedVolume.readDir(path) ?: return@launch
|
||||||
0,
|
if (path != "/") {
|
||||||
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
|
explorerElements.add(
|
||||||
)
|
0,
|
||||||
}
|
ExplorerElement("..", Stat.parentFolderStat(), parentPath = currentDirectoryPath)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.GONE
|
textDirEmpty.visibility = if (explorerElements.size == 0) View.VISIBLE else View.GONE
|
||||||
currentDirectoryPath = path
|
currentDirectoryPath = path
|
||||||
@ -348,22 +380,19 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
displayNumberOfElements(numberOfFilesText, R.string.one_file, R.string.multiple_files, explorerElements.count { it.isRegularFile })
|
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 })
|
displayNumberOfElements(numberOfFoldersText, R.string.one_folder, R.string.multiple_folders, explorerElements.count { it.isDirectory })
|
||||||
if (mapFolders) {
|
if (mapFolders) {
|
||||||
lifecycleScope.launch {
|
var totalSize: Long = 0
|
||||||
var totalSize: Long = 0
|
directoryLoadingTask = launch(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.IO) {
|
for (element in explorerElements) {
|
||||||
synchronized(this@BaseExplorerActivity) {
|
if (element.isDirectory) {
|
||||||
for (element in explorerElements) {
|
recursiveSetSize(element)
|
||||||
if (element.isDirectory) {
|
|
||||||
recursiveSetSize(element)
|
|
||||||
}
|
|
||||||
totalSize += element.stat.size
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
totalSize += element.stat.size
|
||||||
}
|
}
|
||||||
displayExplorerElements()
|
|
||||||
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
|
|
||||||
onDisplayed?.invoke()
|
|
||||||
}
|
}
|
||||||
|
directoryLoadingTask!!.join()
|
||||||
|
displayExplorerElements()
|
||||||
|
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
|
||||||
|
onDisplayed?.invoke()
|
||||||
} else {
|
} else {
|
||||||
displayExplorerElements()
|
displayExplorerElements()
|
||||||
totalSizeText.text = getString(
|
totalSizeText.text = getString(
|
||||||
@ -546,14 +575,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setMenuIconTint(menu: Menu, iconColor: Int, menuItemId: Int, drawableId: Int) {
|
|
||||||
menu.findItem(menuItemId)?.let {
|
|
||||||
it.icon = ContextCompat.getDrawable(this, drawableId)?.apply {
|
|
||||||
setTint(iconColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menu.findItem(R.id.rename).isVisible = false
|
menu.findItem(R.id.rename).isVisible = false
|
||||||
menu.findItem(R.id.open_as)?.isVisible = false
|
menu.findItem(R.id.open_as)?.isVisible = false
|
||||||
@ -561,9 +582,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
menu.findItem(R.id.external_open)?.isVisible = false
|
menu.findItem(R.id.external_open)?.isVisible = false
|
||||||
}
|
}
|
||||||
val noItemSelected = explorerAdapter.selectedItems.isEmpty()
|
val noItemSelected = explorerAdapter.selectedItems.isEmpty()
|
||||||
val iconColor = ContextCompat.getColor(this, R.color.neutralIconTint)
|
with(UIUtils.getMenuIconNeutralTint(this, menu)) {
|
||||||
setMenuIconTint(menu, iconColor, R.id.sort, R.drawable.icon_sort)
|
applyTo(R.id.sort, R.drawable.icon_sort)
|
||||||
setMenuIconTint(menu, iconColor, R.id.share, R.drawable.icon_share)
|
applyTo(R.id.share, R.drawable.icon_share)
|
||||||
|
}
|
||||||
menu.findItem(R.id.sort).isVisible = noItemSelected
|
menu.findItem(R.id.sort).isVisible = noItemSelected
|
||||||
menu.findItem(R.id.lock).isVisible = noItemSelected
|
menu.findItem(R.id.lock).isVisible = noItemSelected
|
||||||
menu.findItem(R.id.close).isVisible = noItemSelected
|
menu.findItem(R.id.close).isVisible = noItemSelected
|
||||||
@ -593,7 +615,13 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
.setTitle(R.string.sort_order)
|
.setTitle(R.string.sort_order)
|
||||||
.setSingleChoiceItems(sortOrderEntries, currentSortOrderIndex) { dialog, which ->
|
.setSingleChoiceItems(sortOrderEntries, currentSortOrderIndex) { dialog, which ->
|
||||||
currentSortOrderIndex = which
|
currentSortOrderIndex = which
|
||||||
displayExplorerElements()
|
// displayExplorerElements must not be called if directoryLoadingTask is active
|
||||||
|
if (directoryLoadingTask?.isActive != true) {
|
||||||
|
displayExplorerElements()
|
||||||
|
}
|
||||||
|
val sharedPrefsEditor = sharedPrefs.edit()
|
||||||
|
sharedPrefsEditor.putString(Constants.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
|
||||||
|
sharedPrefsEditor.apply()
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
@ -644,13 +672,8 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (app.isStartingExternalApp) {
|
if (app.isStartingExternalApp) {
|
||||||
MemoryFileProvider.wipe()
|
TemporaryFileProvider.instance.wipe()
|
||||||
DiskFileProvider.wipe()
|
|
||||||
}
|
|
||||||
if (encryptedVolume.isClosed()) {
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
setCurrentPath(currentDirectoryPath)
|
|
||||||
}
|
}
|
||||||
|
setCurrentPath(currentDirectoryPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -68,7 +68,11 @@ class ExplorerActivity : BaseExplorerActivity() {
|
|||||||
private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
private val pickFiles = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris ->
|
||||||
if (uris != null) {
|
if (uris != null) {
|
||||||
for (uri in uris) {
|
for (uri in uris) {
|
||||||
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
try {
|
||||||
|
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
importFilesFromUris(uris) {
|
importFilesFromUris(uris) {
|
||||||
onImportComplete(uris)
|
onImportComplete(uris)
|
||||||
@ -164,7 +168,7 @@ class ExplorerActivity : BaseExplorerActivity() {
|
|||||||
} else {
|
} else {
|
||||||
val adapter = IconTextDialogAdapter(this)
|
val adapter = IconTextDialogAdapter(this)
|
||||||
adapter.items = listOf(
|
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("importFiles", R.string.import_files, R.drawable.icon_encrypt),
|
||||||
listOf("importFolder", R.string.import_folder, R.drawable.icon_import_folder),
|
listOf("importFolder", R.string.import_folder, R.drawable.icon_import_folder),
|
||||||
listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown),
|
listOf("createFile", R.string.new_file, R.drawable.icon_file_unknown),
|
||||||
@ -177,7 +181,6 @@ class ExplorerActivity : BaseExplorerActivity() {
|
|||||||
"importFromOtherVolumes" -> {
|
"importFromOtherVolumes" -> {
|
||||||
val intent = Intent(this, MainActivity::class.java)
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
intent.action = "pick"
|
intent.action = "pick"
|
||||||
intent.putExtra("volume", encryptedVolume)
|
|
||||||
pickFromOtherVolumes.launch(intent)
|
pickFromOtherVolumes.launch(intent)
|
||||||
}
|
}
|
||||||
"importFiles" -> {
|
"importFiles" -> {
|
||||||
@ -189,9 +192,11 @@ class ExplorerActivity : BaseExplorerActivity() {
|
|||||||
pickImportDirectory.launch(null)
|
pickImportDirectory.launch(null)
|
||||||
}
|
}
|
||||||
"createFile" -> {
|
"createFile" -> {
|
||||||
EditTextDialog(this, R.string.enter_file_name) {
|
createNewFile {
|
||||||
createNewFile(it)
|
encryptedVolume.closeFile(it)
|
||||||
}.show()
|
setCurrentPath(currentDirectoryPath)
|
||||||
|
invalidateOptionsMenu()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"createFolder" -> {
|
"createFolder" -> {
|
||||||
openDialogCreateFolder()
|
openDialogCreateFolder()
|
||||||
@ -199,7 +204,7 @@ class ExplorerActivity : BaseExplorerActivity() {
|
|||||||
"camera" -> {
|
"camera" -> {
|
||||||
val intent = Intent(this, CameraActivity::class.java)
|
val intent = Intent(this, CameraActivity::class.java)
|
||||||
intent.putExtra("path", currentDirectoryPath)
|
intent.putExtra("path", currentDirectoryPath)
|
||||||
intent.putExtra("volume", encryptedVolume)
|
intent.putExtra("volumeId", volumeId)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -219,26 +224,6 @@ class ExplorerActivity : BaseExplorerActivity() {
|
|||||||
cancelItemAction()
|
cancelItemAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNewFile(fileName: String){
|
|
||||||
if (fileName.isEmpty()) {
|
|
||||||
Toast.makeText(this, R.string.error_filename_empty, Toast.LENGTH_SHORT).show()
|
|
||||||
} else {
|
|
||||||
val filePath = PathUtils.pathJoin(currentDirectoryPath, fileName)
|
|
||||||
val handleID = encryptedVolume.openFileWriteMode(filePath)
|
|
||||||
if (handleID == -1L) {
|
|
||||||
CustomAlertDialogBuilder(this, theme)
|
|
||||||
.setTitle(R.string.error)
|
|
||||||
.setMessage(R.string.file_creation_failed)
|
|
||||||
.setPositiveButton(R.string.ok, null)
|
|
||||||
.show()
|
|
||||||
} else {
|
|
||||||
encryptedVolume.closeFile(handleID)
|
|
||||||
setCurrentPath(currentDirectoryPath)
|
|
||||||
invalidateOptionsMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.explorer, menu)
|
menuInflater.inflate(R.menu.explorer, menu)
|
||||||
val result = super.onCreateOptionsMenu(menu)
|
val result = super.onCreateOptionsMenu(menu)
|
||||||
@ -343,8 +328,8 @@ class ExplorerActivity : BaseExplorerActivity() {
|
|||||||
activityScope.launch {
|
activityScope.launch {
|
||||||
onTaskResult(
|
onTaskResult(
|
||||||
fileOperationService.moveElements(volumeId, toMove, toClean),
|
fileOperationService.moveElements(volumeId, toMove, toClean),
|
||||||
R.string.move_success,
|
|
||||||
R.string.move_failed,
|
R.string.move_failed,
|
||||||
|
R.string.move_success,
|
||||||
)
|
)
|
||||||
setCurrentPath(currentDirectoryPath)
|
setCurrentPath(currentDirectoryPath)
|
||||||
}
|
}
|
||||||
@ -385,26 +370,24 @@ class ExplorerActivity : BaseExplorerActivity() {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.share -> {
|
R.id.share -> {
|
||||||
app.isStartingExternalApp = true
|
|
||||||
val files = explorerAdapter.selectedItems.map { i ->
|
val files = explorerAdapter.selectedItems.map { i ->
|
||||||
explorerElements[i].let {
|
explorerElements[i].let {
|
||||||
Pair(it.fullPath, it.stat.size)
|
Pair(it.fullPath, it.stat.size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
object : LoadingTask<Pair<Intent?, String?>>(this, theme, R.string.loading_msg_export) {
|
app.isExporting = true
|
||||||
override suspend fun doTask(): Pair<Intent?, String?> {
|
object : LoadingTask<Pair<Intent?, Int?>>(this, theme, R.string.loading_msg_export) {
|
||||||
return fileShare.share(files)
|
override suspend fun doTask(): Pair<Intent?, Int?> {
|
||||||
|
return fileShare.share(files, volumeId)
|
||||||
}
|
}
|
||||||
}.startTask(lifecycleScope) { (intent, failedItem) ->
|
}.startTask(lifecycleScope) { (intent, error) ->
|
||||||
if (intent == null) {
|
if (intent == null) {
|
||||||
CustomAlertDialogBuilder(this, theme)
|
onExportFailed(error!!)
|
||||||
.setTitle(R.string.error)
|
|
||||||
.setMessage(getString(R.string.export_failed, failedItem))
|
|
||||||
.setPositiveButton(R.string.ok, null)
|
|
||||||
.show()
|
|
||||||
} else {
|
} else {
|
||||||
|
app.isStartingExternalApp = true
|
||||||
startActivity(Intent.createChooser(intent, getString(R.string.share_chooser)))
|
startActivity(Intent.createChooser(intent, getString(R.string.share_chooser)))
|
||||||
}
|
}
|
||||||
|
app.isExporting = false
|
||||||
}
|
}
|
||||||
unselectAll()
|
unselectAll()
|
||||||
true
|
true
|
||||||
|
@ -9,6 +9,8 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
|
|||||||
import sushi.hardcore.droidfs.R
|
import sushi.hardcore.droidfs.R
|
||||||
import sushi.hardcore.droidfs.util.IntentUtils
|
import sushi.hardcore.droidfs.util.IntentUtils
|
||||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
|
import java.nio.CharBuffer
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
class ExplorerActivityDrop : BaseExplorerActivity() {
|
class ExplorerActivityDrop : BaseExplorerActivity() {
|
||||||
|
|
||||||
@ -30,15 +32,15 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
|
|||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.validate -> {
|
R.id.validate -> {
|
||||||
val extras = intent.extras
|
val extras = intent.extras
|
||||||
val errorMsg: String? = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
|
val success = if (extras != null && extras.containsKey(Intent.EXTRA_STREAM)) {
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
Intent.ACTION_SEND -> {
|
Intent.ACTION_SEND -> {
|
||||||
val uri = IntentUtils.getParcelableExtra<Uri>(intent, Intent.EXTRA_STREAM)
|
val uri = IntentUtils.getParcelableExtra<Uri>(intent, Intent.EXTRA_STREAM)
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
getString(R.string.share_intent_parsing_failed)
|
false
|
||||||
} else {
|
} else {
|
||||||
importFilesFromUris(listOf(uri), ::onImported)
|
importFilesFromUris(listOf(uri), ::onImported)
|
||||||
null
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Intent.ACTION_SEND_MULTIPLE -> {
|
Intent.ACTION_SEND_MULTIPLE -> {
|
||||||
@ -50,20 +52,34 @@ class ExplorerActivityDrop : BaseExplorerActivity() {
|
|||||||
}
|
}
|
||||||
if (uris != null) {
|
if (uris != null) {
|
||||||
importFilesFromUris(uris, ::onImported)
|
importFilesFromUris(uris, ::onImported)
|
||||||
null
|
true
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.share_intent_parsing_failed)
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> getString(R.string.share_intent_parsing_failed)
|
else -> false
|
||||||
}
|
}
|
||||||
|
} else if ((intent.clipData?.itemCount ?: 0) > 0) {
|
||||||
|
val byteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(intent.clipData!!.getItemAt(0).text))
|
||||||
|
val byteArray = ByteArray(byteBuffer.remaining())
|
||||||
|
byteBuffer.get(byteArray)
|
||||||
|
val size = byteArray.size.toLong()
|
||||||
|
createNewFile {
|
||||||
|
var offset = 0L
|
||||||
|
while (offset < size) {
|
||||||
|
offset += encryptedVolume.write(it, offset, byteArray, offset, size-offset)
|
||||||
|
}
|
||||||
|
encryptedVolume.closeFile(it)
|
||||||
|
onImported()
|
||||||
|
}
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
getString(R.string.share_intent_parsing_failed)
|
false
|
||||||
}
|
}
|
||||||
errorMsg?.let {
|
if (!success) {
|
||||||
CustomAlertDialogBuilder(this, theme)
|
CustomAlertDialogBuilder(this, theme)
|
||||||
.setTitle(R.string.error)
|
.setTitle(R.string.error)
|
||||||
.setMessage(it)
|
.setMessage(R.string.share_intent_parsing_failed)
|
||||||
.setPositiveButton(R.string.ok, null)
|
.setPositiveButton(R.string.ok, null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import sushi.hardcore.droidfs.util.PathUtils
|
|||||||
import java.text.Collator
|
import java.text.Collator
|
||||||
|
|
||||||
class ExplorerElement(val name: String, val stat: Stat, val parentPath: String) {
|
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 collationKey = Collator.getInstance().getCollationKeyForFileName(fullPath)
|
||||||
|
|
||||||
val isDirectory: Boolean
|
val isDirectory: Boolean
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
package sushi.hardcore.droidfs.file_operations
|
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
|
|
||||||
class FileOperationNotification(val notificationBuilder: NotificationCompat.Builder, val notificationId: Int)
|
|
@ -1,65 +1,184 @@
|
|||||||
package sushi.hardcore.droidfs.file_operations
|
package sushi.hardcore.droidfs.file_operations
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.MainScope
|
import kotlinx.coroutines.MainScope
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
|
import sushi.hardcore.droidfs.BaseActivity
|
||||||
import sushi.hardcore.droidfs.Constants
|
import sushi.hardcore.droidfs.Constants
|
||||||
|
import sushi.hardcore.droidfs.NotificationBroadcastReceiver
|
||||||
import sushi.hardcore.droidfs.R
|
import sushi.hardcore.droidfs.R
|
||||||
import sushi.hardcore.droidfs.VolumeManager
|
import sushi.hardcore.droidfs.VolumeManager
|
||||||
import sushi.hardcore.droidfs.VolumeManagerApp
|
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
|
import sushi.hardcore.droidfs.filesystems.Stat
|
||||||
|
import sushi.hardcore.droidfs.util.AndroidUtils
|
||||||
import sushi.hardcore.droidfs.util.ObjRef
|
import sushi.hardcore.droidfs.util.ObjRef
|
||||||
import sushi.hardcore.droidfs.util.PathUtils
|
import sushi.hardcore.droidfs.util.PathUtils
|
||||||
import sushi.hardcore.droidfs.util.Wiper
|
import sushi.hardcore.droidfs.util.Wiper
|
||||||
|
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service for file operations.
|
||||||
|
*
|
||||||
|
* Clients **must** bind to it using the [bind] method.
|
||||||
|
*
|
||||||
|
* This implementation is not thread-safe. It must only be called from the main UI thread.
|
||||||
|
*/
|
||||||
class FileOperationService : Service() {
|
class FileOperationService : Service() {
|
||||||
companion object {
|
|
||||||
const val NOTIFICATION_CHANNEL_ID = "FileOperations"
|
|
||||||
const val ACTION_CANCEL = "file_operation_cancel"
|
|
||||||
}
|
|
||||||
|
|
||||||
private val binder = LocalBinder()
|
|
||||||
private lateinit var volumeManger: VolumeManager
|
|
||||||
private var serviceScope = MainScope()
|
|
||||||
private lateinit var notificationManager: NotificationManagerCompat
|
|
||||||
private val tasks = HashMap<Int, Job>()
|
|
||||||
private var lastNotificationId = 0
|
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
inner class LocalBinder : Binder() {
|
||||||
fun getService(): FileOperationService = this@FileOperationService
|
fun getService(): FileOperationService = this@FileOperationService
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(p0: Intent?): IBinder {
|
inner class PendingTask<T>(
|
||||||
volumeManger = (application as VolumeManagerApp).volumeManager
|
val title: Int,
|
||||||
return binder
|
val total: Int?,
|
||||||
|
private val getTask: (Int) -> Deferred<T>,
|
||||||
|
private val onStart: (taskId: Int, job: Deferred<T>) -> Unit,
|
||||||
|
) {
|
||||||
|
fun start(taskId: Int): Deferred<T> = getTask(taskId).also { onStart(taskId, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showNotification(message: Int, total: Int?): FileOperationNotification {
|
companion object {
|
||||||
++lastNotificationId
|
const val TAG = "FileOperationService"
|
||||||
if (!::notificationManager.isInitialized){
|
const val NOTIFICATION_CHANNEL_ID = "FileOperations"
|
||||||
|
const val ACTION_CANCEL = "file_operation_cancel"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind to the service.
|
||||||
|
*
|
||||||
|
* Registers an [ActivityResultLauncher] in the provided activity to request notification permission. Consequently, the activity must not yet be started.
|
||||||
|
*
|
||||||
|
* The activity must stay running while calling the service's methods.
|
||||||
|
*
|
||||||
|
* If multiple activities bind simultaneously, only the latest one will be used by the service.
|
||||||
|
*/
|
||||||
|
fun bind(activity: BaseActivity, onBound: (FileOperationService) -> Unit) {
|
||||||
|
val helper = AndroidUtils.NotificationPermissionHelper(activity)
|
||||||
|
lateinit var service: FileOperationService
|
||||||
|
val serviceConnection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(className: ComponentName, binder: IBinder) {
|
||||||
|
onBound((binder as FileOperationService.LocalBinder).getService().also {
|
||||||
|
service = it
|
||||||
|
it.notificationPermissionHelpers.addLast(helper)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
override fun onServiceDisconnected(arg0: ComponentName) {}
|
||||||
|
}
|
||||||
|
activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||||
|
override fun onDestroy(owner: LifecycleOwner) {
|
||||||
|
activity.unbindService(serviceConnection)
|
||||||
|
// Could have been more efficient with a LinkedHashMap but the JDK implementation doesn't allow
|
||||||
|
// to access the latest element in O(1) unless using reflection
|
||||||
|
service.notificationPermissionHelpers.removeAll { it.activity == activity }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
activity.bindService(
|
||||||
|
Intent(activity, FileOperationService::class.java),
|
||||||
|
serviceConnection,
|
||||||
|
Context.BIND_AUTO_CREATE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isStarted = false
|
||||||
|
private val binder = LocalBinder()
|
||||||
|
private lateinit var volumeManger: VolumeManager
|
||||||
|
private var serviceScope = MainScope()
|
||||||
|
private val notificationPermissionHelpers = ArrayDeque<AndroidUtils.NotificationPermissionHelper<BaseActivity>>(2)
|
||||||
|
private var askForNotificationPermission = true
|
||||||
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
private val notifications = HashMap<Int, NotificationCompat.Builder>()
|
||||||
|
private var foregroundNotificationId = -1
|
||||||
|
private val tasks = HashMap<Int, Job>()
|
||||||
|
private var newTaskId = 1
|
||||||
|
private var pendingTask: PendingTask<*>? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
volumeManger = (application as VolumeManagerApp).volumeManager
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(p0: Intent?): IBinder = binder
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
startPendingTask { id, notification ->
|
||||||
|
// on service start, the pending task is the foreground task
|
||||||
|
setForeground(id, notification)
|
||||||
|
}
|
||||||
|
isStarted = true
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
isStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processPendingTask() {
|
||||||
|
if (isStarted) {
|
||||||
|
startPendingTask { id, notification ->
|
||||||
|
if (foregroundNotificationId == -1) {
|
||||||
|
// service started but not in foreground yet
|
||||||
|
setForeground(id, notification)
|
||||||
|
} else {
|
||||||
|
// already running in foreground, just add a new notification
|
||||||
|
notificationManager.notify(id, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
|
this,
|
||||||
|
Intent(this, FileOperationService::class.java)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the pending task and create an associated notification.
|
||||||
|
*/
|
||||||
|
private fun startPendingTask(showNotification: (id: Int, Notification) -> Unit) {
|
||||||
|
val task = pendingTask
|
||||||
|
pendingTask = null
|
||||||
|
if (task == null) {
|
||||||
|
Log.w(TAG, "Started without pending task")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!::notificationManager.isInitialized) {
|
||||||
notificationManager = NotificationManagerCompat.from(this)
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
@ -72,87 +191,187 @@ class FileOperationService : Service() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
notificationBuilder
|
.setContentTitle(getString(task.title))
|
||||||
.setContentTitle(getString(message))
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setOngoing(true)
|
||||||
.setOngoing(true)
|
.addAction(NotificationCompat.Action(
|
||||||
.addAction(NotificationCompat.Action(
|
R.drawable.icon_close,
|
||||||
R.drawable.icon_close,
|
getString(R.string.cancel),
|
||||||
getString(R.string.cancel),
|
PendingIntent.getBroadcast(
|
||||||
PendingIntent.getBroadcast(
|
this,
|
||||||
this,
|
newTaskId,
|
||||||
0,
|
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
||||||
Intent(this, NotificationBroadcastReceiver::class.java).apply {
|
putExtra("bundle", Bundle().apply {
|
||||||
val bundle = Bundle()
|
putBinder("binder", LocalBinder())
|
||||||
bundle.putBinder("binder", LocalBinder())
|
putInt("taskId", newTaskId)
|
||||||
bundle.putInt("notificationId", lastNotificationId)
|
})
|
||||||
putExtra("bundle", bundle)
|
action = ACTION_CANCEL
|
||||||
action = ACTION_CANCEL
|
},
|
||||||
},
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
)
|
||||||
)
|
))
|
||||||
))
|
if (task.total != null) {
|
||||||
if (total != null) {
|
|
||||||
notificationBuilder
|
notificationBuilder
|
||||||
.setContentText("0/$total")
|
.setContentText("0/${task.total}")
|
||||||
.setProgress(total, 0, false)
|
.setProgress(task.total, 0, false)
|
||||||
} else {
|
} else {
|
||||||
notificationBuilder
|
notificationBuilder
|
||||||
.setContentText(getString(R.string.discovering_files))
|
.setContentText(getString(R.string.discovering_files))
|
||||||
.setProgress(0, 0, true)
|
.setProgress(0, 0, true)
|
||||||
}
|
}
|
||||||
notificationManager.notify(lastNotificationId, notificationBuilder.build())
|
showNotification(newTaskId, notificationBuilder.build())
|
||||||
return FileOperationNotification(notificationBuilder, lastNotificationId)
|
notifications[newTaskId] = notificationBuilder
|
||||||
|
tasks[newTaskId] = task.start(newTaskId)
|
||||||
|
newTaskId++
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateNotificationProgress(notification: FileOperationNotification, progress: Int, total: Int){
|
private fun setForeground(id: Int, notification: Notification) {
|
||||||
notification.notificationBuilder
|
ServiceCompat.startForeground(this, id, notification,
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
foregroundNotificationId = id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotificationProgress(taskId: Int, progress: Int, total: Int) {
|
||||||
|
val notificationBuilder = notifications[taskId] ?: return
|
||||||
|
notificationBuilder
|
||||||
.setProgress(total, progress, false)
|
.setProgress(total, progress, false)
|
||||||
.setContentText("$progress/$total")
|
.setContentText("$progress/$total")
|
||||||
notificationManager.notify(notification.notificationId, notification.notificationBuilder.build())
|
notificationManager.notify(taskId, notificationBuilder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelNotification(notification: FileOperationNotification){
|
fun cancelOperation(taskId: Int) {
|
||||||
notificationManager.cancel(notification.notificationId)
|
tasks[taskId]?.cancel()
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelOperation(notificationId: Int){
|
|
||||||
tasks[notificationId]?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
|
private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
|
||||||
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
|
return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<out T> {
|
/**
|
||||||
tasks[notification.notificationId] = task
|
* Wait on a task, returning the appropriate [TaskResult].
|
||||||
|
*
|
||||||
|
* This method also performs cleanup and foreground state management so it must be always used.
|
||||||
|
*/
|
||||||
|
private suspend fun <T> waitForTask(
|
||||||
|
taskId: Int,
|
||||||
|
task: Deferred<T>,
|
||||||
|
onCancelled: (suspend () -> Unit)?,
|
||||||
|
): TaskResult<out T> {
|
||||||
return coroutineScope {
|
return coroutineScope {
|
||||||
withContext(serviceScope.coroutineContext) {
|
withContext(serviceScope.coroutineContext) {
|
||||||
try {
|
try {
|
||||||
TaskResult.completed(task.await())
|
TaskResult.completed(task.await())
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
|
onCancelled?.invoke()
|
||||||
TaskResult.cancelled()
|
TaskResult.cancelled()
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
TaskResult.error(e.localizedMessage)
|
TaskResult.error(e.localizedMessage)
|
||||||
} finally {
|
} finally {
|
||||||
cancelNotification(notification)
|
notificationManager.cancel(taskId)
|
||||||
|
notifications.remove(taskId)
|
||||||
|
tasks.remove(taskId)
|
||||||
|
if (tasks.size == 0) {
|
||||||
|
// last task finished, remove from foreground state but don't stop the service
|
||||||
|
ServiceCompat.stopForeground(this@FileOperationService, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
foregroundNotificationId = -1
|
||||||
|
} else if (taskId == foregroundNotificationId) {
|
||||||
|
// foreground task finished, falling back to the next one
|
||||||
|
val entry = notifications.entries.first()
|
||||||
|
setForeground(entry.key, entry.value.build())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <T> volumeTask(
|
/**
|
||||||
volumeId: Int,
|
* Create and run a new task until completion.
|
||||||
notification: FileOperationNotification,
|
*
|
||||||
task: suspend (encryptedVolume: EncryptedVolume) -> T
|
* Handles notification permission request, service startup and notification management.
|
||||||
|
*
|
||||||
|
* Overrides [pendingTask] without checking! (safe if user is not insanely fast)
|
||||||
|
*/
|
||||||
|
private suspend fun <T> newTask(
|
||||||
|
title: Int,
|
||||||
|
total: Int?,
|
||||||
|
getTask: (taskId: Int) -> Deferred<T>,
|
||||||
|
onCancelled: (suspend () -> Unit)?,
|
||||||
): TaskResult<out T> {
|
): TaskResult<out T> {
|
||||||
return waitForTask(
|
val startedTask = suspendCoroutine { continuation ->
|
||||||
notification,
|
val task = PendingTask(title, total, getTask) { taskId, job ->
|
||||||
volumeManger.getCoroutineScope(volumeId).async {
|
continuation.resume(Pair(taskId, job))
|
||||||
task(getEncryptedVolume(volumeId))
|
|
||||||
}
|
}
|
||||||
)
|
pendingTask = task
|
||||||
|
if (askForNotificationPermission) {
|
||||||
|
with (notificationPermissionHelpers.last()) {
|
||||||
|
askAndRun { granted ->
|
||||||
|
if (granted) {
|
||||||
|
processPendingTask()
|
||||||
|
} else {
|
||||||
|
CustomAlertDialogBuilder(activity, activity.theme)
|
||||||
|
.setTitle(R.string.warning)
|
||||||
|
.setMessage(R.string.notification_denied_msg)
|
||||||
|
.setPositiveButton(R.string.settings) { _, _ ->
|
||||||
|
(application as VolumeManagerApp).isStartingExternalApp = true
|
||||||
|
activity.startActivity(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.fromParts("package", packageName, null)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.later, null)
|
||||||
|
.setOnDismissListener { processPendingTask() }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
askForNotificationPermission = false // only ask once per service instance
|
||||||
|
return@suspendCoroutine
|
||||||
|
}
|
||||||
|
processPendingTask()
|
||||||
|
}
|
||||||
|
return waitForTask(startedTask.first, startedTask.second, onCancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> volumeTask(
|
||||||
|
title: Int,
|
||||||
|
total: Int?,
|
||||||
|
volumeId: Int,
|
||||||
|
task: suspend (taskId: Int, encryptedVolume: EncryptedVolume) -> T
|
||||||
|
): TaskResult<out T> {
|
||||||
|
return newTask(title, total, { taskId ->
|
||||||
|
volumeManger.getCoroutineScope(volumeId).async {
|
||||||
|
task(taskId, getEncryptedVolume(volumeId))
|
||||||
|
}
|
||||||
|
}, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> globalTask(
|
||||||
|
title: Int,
|
||||||
|
total: Int?,
|
||||||
|
task: suspend (taskId: Int) -> T,
|
||||||
|
onCancelled: (suspend () -> Unit)? = null,
|
||||||
|
): TaskResult<out T> {
|
||||||
|
return newTask(title, total, { taskId ->
|
||||||
|
serviceScope.async(Dispatchers.IO) {
|
||||||
|
task(taskId)
|
||||||
|
}
|
||||||
|
}, if (onCancelled == null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
serviceScope.launch(Dispatchers.IO) {
|
||||||
|
onCancelled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun copyFile(
|
private suspend fun copyFile(
|
||||||
@ -196,9 +415,8 @@ class FileOperationService : Service() {
|
|||||||
items: List<OperationFile>,
|
items: List<OperationFile>,
|
||||||
srcVolumeId: Int = volumeId,
|
srcVolumeId: Int = volumeId,
|
||||||
): TaskResult<out String?> {
|
): TaskResult<out String?> {
|
||||||
val notification = showNotification(R.string.file_op_copy_msg, items.size)
|
|
||||||
val srcEncryptedVolume = getEncryptedVolume(srcVolumeId)
|
val srcEncryptedVolume = getEncryptedVolume(srcVolumeId)
|
||||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
return volumeTask(R.string.file_op_copy_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||||
var failedItem: String? = null
|
var failedItem: String? = null
|
||||||
for (i in items.indices) {
|
for (i in items.indices) {
|
||||||
yield()
|
yield()
|
||||||
@ -212,7 +430,7 @@ class FileOperationService : Service() {
|
|||||||
failedItem = items[i].srcPath
|
failedItem = items[i].srcPath
|
||||||
}
|
}
|
||||||
if (failedItem == null) {
|
if (failedItem == null) {
|
||||||
updateNotificationProgress(notification, i+1, items.size)
|
updateNotificationProgress(taskId, i+1, items.size)
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -222,8 +440,7 @@ class FileOperationService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
|
suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
|
||||||
val notification = showNotification(R.string.file_op_move_msg, toMove.size)
|
return volumeTask(R.string.file_op_move_msg, toMove.size, volumeId) { taskId, encryptedVolume ->
|
||||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
|
||||||
val total = toMove.size+toClean.size
|
val total = toMove.size+toClean.size
|
||||||
var failedItem: String? = null
|
var failedItem: String? = null
|
||||||
for ((i, item) in toMove.withIndex()) {
|
for ((i, item) in toMove.withIndex()) {
|
||||||
@ -231,7 +448,7 @@ class FileOperationService : Service() {
|
|||||||
failedItem = item.srcPath
|
failedItem = item.srcPath
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
updateNotificationProgress(notification, i+1, total)
|
updateNotificationProgress(taskId, i+1, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (failedItem == null) {
|
if (failedItem == null) {
|
||||||
@ -240,7 +457,7 @@ class FileOperationService : Service() {
|
|||||||
failedItem = folderPath
|
failedItem = folderPath
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
updateNotificationProgress(notification, toMove.size+i+1, total)
|
updateNotificationProgress(taskId, toMove.size+i+1, total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -252,7 +469,7 @@ class FileOperationService : Service() {
|
|||||||
encryptedVolume: EncryptedVolume,
|
encryptedVolume: EncryptedVolume,
|
||||||
dstPaths: List<String>,
|
dstPaths: List<String>,
|
||||||
uris: List<Uri>,
|
uris: List<Uri>,
|
||||||
notification: FileOperationNotification,
|
taskId: Int,
|
||||||
): String? {
|
): String? {
|
||||||
var failedIndex = -1
|
var failedIndex = -1
|
||||||
for (i in dstPaths.indices) {
|
for (i in dstPaths.indices) {
|
||||||
@ -265,7 +482,7 @@ class FileOperationService : Service() {
|
|||||||
failedIndex = i
|
failedIndex = i
|
||||||
}
|
}
|
||||||
if (failedIndex == -1) {
|
if (failedIndex == -1) {
|
||||||
updateNotificationProgress(notification, i+1, dstPaths.size)
|
updateNotificationProgress(taskId, i+1, dstPaths.size)
|
||||||
} else {
|
} else {
|
||||||
return uris[failedIndex].toString()
|
return uris[failedIndex].toString()
|
||||||
}
|
}
|
||||||
@ -274,9 +491,8 @@ class FileOperationService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
|
suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
|
||||||
val notification = showNotification(R.string.file_op_import_msg, dstPaths.size)
|
return volumeTask(R.string.file_op_import_msg, dstPaths.size, volumeId) { taskId, encryptedVolume ->
|
||||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
importFilesFromUris(encryptedVolume, dstPaths, uris, taskId)
|
||||||
importFilesFromUris(encryptedVolume, dstPaths, uris, notification)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,8 +500,6 @@ class FileOperationService : Service() {
|
|||||||
* Map the content of an unencrypted directory to prepare its import
|
* Map the content of an unencrypted directory to prepare its import
|
||||||
*
|
*
|
||||||
* Contents of dstFiles and srcUris, at the same index, will match each other
|
* Contents of dstFiles and srcUris, at the same index, will match each other
|
||||||
*
|
|
||||||
* @return false if cancelled early, true otherwise.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun recursiveMapDirectoryForImport(
|
private suspend fun recursiveMapDirectoryForImport(
|
||||||
rootSrcDir: DocumentFile,
|
rootSrcDir: DocumentFile,
|
||||||
@ -316,36 +530,35 @@ class FileOperationService : Service() {
|
|||||||
rootDstPath: String,
|
rootDstPath: String,
|
||||||
rootSrcDir: DocumentFile,
|
rootSrcDir: DocumentFile,
|
||||||
): ImportDirectoryResult {
|
): ImportDirectoryResult {
|
||||||
val notification = showNotification(R.string.file_op_import_msg, null)
|
|
||||||
val srcUris = arrayListOf<Uri>()
|
val srcUris = arrayListOf<Uri>()
|
||||||
return ImportDirectoryResult(volumeTask(volumeId, notification) { encryptedVolume ->
|
return ImportDirectoryResult(volumeTask(R.string.file_op_import_msg, null, volumeId) { taskId, encryptedVolume ->
|
||||||
var failedItem: String? = null
|
var failedItem: String? = null
|
||||||
val dstFiles = arrayListOf<String>()
|
val dstFiles = arrayListOf<String>()
|
||||||
val dstDirs = arrayListOf<String>()
|
val dstDirs = arrayListOf<String>()
|
||||||
recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs)
|
recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs)
|
||||||
// create destination folders so the new files can use them
|
// create destination folders so the new files can use them
|
||||||
for (dir in dstDirs) {
|
for (dir in dstDirs) {
|
||||||
if (!encryptedVolume.mkdir(dir)) {
|
// if directory creation fails, check if it was already present
|
||||||
|
if (!encryptedVolume.mkdir(dir) && encryptedVolume.getAttr(dir)?.type != Stat.S_IFDIR) {
|
||||||
failedItem = dir
|
failedItem = dir
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (failedItem == null) {
|
if (failedItem == null) {
|
||||||
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, notification)
|
failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, taskId)
|
||||||
}
|
}
|
||||||
failedItem
|
failedItem
|
||||||
}, srcUris)
|
}, srcUris)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
|
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
|
||||||
val notification = showNotification(R.string.file_op_wiping_msg, uris.size)
|
return globalTask(R.string.file_op_wiping_msg, uris.size, { taskId ->
|
||||||
val task = serviceScope.async(Dispatchers.IO) {
|
|
||||||
var errorMsg: String? = null
|
var errorMsg: String? = null
|
||||||
for (i in uris.indices) {
|
for (i in uris.indices) {
|
||||||
yield()
|
yield()
|
||||||
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
|
errorMsg = Wiper.wipe(this@FileOperationService, uris[i])
|
||||||
if (errorMsg == null) {
|
if (errorMsg == null) {
|
||||||
updateNotificationProgress(notification, i+1, uris.size)
|
updateNotificationProgress(taskId, i+1, uris.size)
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -354,8 +567,7 @@ class FileOperationService : Service() {
|
|||||||
rootFile?.delete()
|
rootFile?.delete()
|
||||||
}
|
}
|
||||||
errorMsg
|
errorMsg
|
||||||
}
|
})
|
||||||
return waitForTask(notification, task)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
|
private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
|
||||||
@ -391,8 +603,7 @@ class FileOperationService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
|
suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
|
||||||
val notification = showNotification(R.string.file_op_export_msg, items.size)
|
return volumeTask(R.string.file_op_export_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
|
||||||
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
|
val treeDocumentFile = DocumentFile.fromTreeUri(this@FileOperationService, uri)!!
|
||||||
var failedItem: String? = null
|
var failedItem: String? = null
|
||||||
for (i in items.indices) {
|
for (i in items.indices) {
|
||||||
@ -407,7 +618,7 @@ class FileOperationService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (failedItem == null) {
|
if (failedItem == null) {
|
||||||
updateNotificationProgress(notification, i+1, items.size)
|
updateNotificationProgress(taskId, i+1, items.size)
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -436,8 +647,7 @@ class FileOperationService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
|
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
|
||||||
val notification = showNotification(R.string.file_op_delete_msg, items.size)
|
return volumeTask(R.string.file_op_delete_msg, items.size, volumeId) { taskId, encryptedVolume ->
|
||||||
return volumeTask(volumeId, notification) { encryptedVolume ->
|
|
||||||
var failedItem: String? = null
|
var failedItem: String? = null
|
||||||
for ((i, element) in items.withIndex()) {
|
for ((i, element) in items.withIndex()) {
|
||||||
yield()
|
yield()
|
||||||
@ -447,7 +657,7 @@ class FileOperationService : Service() {
|
|||||||
failedItem = element.fullPath
|
failedItem = element.fullPath
|
||||||
}
|
}
|
||||||
if (failedItem == null) {
|
if (failedItem == null) {
|
||||||
updateNotificationProgress(notification, i + 1, items.size)
|
updateNotificationProgress(taskId, i + 1, items.size)
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -456,13 +666,13 @@ class FileOperationService : Service() {
|
|||||||
}.failedItem // treat cancellation as success
|
}.failedItem // treat cancellation as success
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int {
|
private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile): Int {
|
||||||
yield()
|
yield()
|
||||||
val children = rootDirectory.listFiles()
|
val children = rootDirectory.listFiles()
|
||||||
var count = children.size
|
var count = children.size
|
||||||
for (child in children) {
|
for (child in children) {
|
||||||
if (child.isDirectory) {
|
if (child.isDirectory) {
|
||||||
count += recursiveCountChildElements(child, scope)
|
count += recursiveCountChildElements(child)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
@ -472,9 +682,8 @@ class FileOperationService : Service() {
|
|||||||
src: DocumentFile,
|
src: DocumentFile,
|
||||||
dst: DocumentFile,
|
dst: DocumentFile,
|
||||||
dstRootDirectory: ObjRef<DocumentFile?>?,
|
dstRootDirectory: ObjRef<DocumentFile?>?,
|
||||||
notification: FileOperationNotification,
|
taskId: Int,
|
||||||
total: Int,
|
total: Int,
|
||||||
scope: CoroutineScope,
|
|
||||||
progress: ObjRef<Int> = ObjRef(0)
|
progress: ObjRef<Int> = ObjRef(0)
|
||||||
): DocumentFile? {
|
): DocumentFile? {
|
||||||
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
|
val dstDir = dst.createDirectory(src.name ?: return src) ?: return src
|
||||||
@ -491,10 +700,10 @@ class FileOperationService : Service() {
|
|||||||
inputStream.close()
|
inputStream.close()
|
||||||
if (written != child.length()) return child
|
if (written != child.length()) return child
|
||||||
} else {
|
} else {
|
||||||
recursiveCopyVolume(child, dstDir, null, notification, total, scope, progress)?.let { return it }
|
recursiveCopyVolume(child, dstDir, null, taskId, total, progress)?.let { return it }
|
||||||
}
|
}
|
||||||
progress.value++
|
progress.value++
|
||||||
updateNotificationProgress(notification, progress.value, total)
|
updateNotificationProgress(taskId, progress.value, total)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -502,13 +711,14 @@ class FileOperationService : Service() {
|
|||||||
class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
|
class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
|
||||||
|
|
||||||
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
|
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
|
||||||
val notification = showNotification(R.string.copy_volume_notification, null)
|
|
||||||
val dstRootDirectory = ObjRef<DocumentFile?>(null)
|
val dstRootDirectory = ObjRef<DocumentFile?>(null)
|
||||||
val task = serviceScope.async(Dispatchers.IO) {
|
val result = globalTask(R.string.copy_volume_notification, null, { taskId ->
|
||||||
val total = recursiveCountChildElements(src, this)
|
val total = recursiveCountChildElements(src)
|
||||||
updateNotificationProgress(notification, 0, total)
|
updateNotificationProgress(taskId, 0, total)
|
||||||
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this)
|
recursiveCopyVolume(src, dst, dstRootDirectory, taskId, total)
|
||||||
}
|
}, {
|
||||||
return CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
|
dstRootDirectory.value?.delete()
|
||||||
|
})
|
||||||
|
return CopyVolumeResult(result, dstRootDirectory.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,19 +0,0 @@
|
|||||||
package sushi.hardcore.droidfs.file_operations
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
|
|
||||||
class NotificationBroadcastReceiver: BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
if (intent.action == FileOperationService.ACTION_CANCEL){
|
|
||||||
intent.getBundleExtra("bundle")?.let { bundle ->
|
|
||||||
(bundle.getBinder("binder") as FileOperationService.LocalBinder?)?.let { binder ->
|
|
||||||
val notificationId = bundle.getInt("notificationId")
|
|
||||||
val service = binder.getService()
|
|
||||||
service.cancelOperation(notificationId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +1,11 @@
|
|||||||
package sushi.hardcore.droidfs.file_viewers
|
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
|
import sushi.hardcore.droidfs.databinding.ActivityAudioPlayerBinding
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
class AudioPlayer: MediaPlayer(){
|
class AudioPlayer: MediaPlayer(){
|
||||||
private lateinit var binding: ActivityAudioPlayerBinding
|
private lateinit var binding: ActivityAudioPlayerBinding
|
||||||
|
|
||||||
|
@ -1,15 +1,19 @@
|
|||||||
package sushi.hardcore.droidfs.file_viewers
|
package sushi.hardcore.droidfs.file_viewers
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.google.android.exoplayer2.C
|
import androidx.media3.common.C
|
||||||
import com.google.android.exoplayer2.upstream.DataSource
|
import androidx.annotation.OptIn
|
||||||
import com.google.android.exoplayer2.upstream.DataSpec
|
import androidx.media3.common.util.UnstableApi
|
||||||
import com.google.android.exoplayer2.upstream.TransferListener
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.datasource.DataSpec
|
||||||
|
import androidx.media3.datasource.TransferListener
|
||||||
import sushi.hardcore.droidfs.Constants
|
import sushi.hardcore.droidfs.Constants
|
||||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
import kotlin.math.min
|
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 fileHandle = -1L
|
||||||
private var fileOffset: Long = 0
|
private var fileOffset: Long = 0
|
||||||
private var bytesRemaining: Long = -1
|
private var bytesRemaining: Long = -1
|
||||||
|
@ -14,14 +14,17 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sushi.hardcore.droidfs.BaseActivity
|
import sushi.hardcore.droidfs.BaseActivity
|
||||||
import sushi.hardcore.droidfs.FileTypes
|
import sushi.hardcore.droidfs.FileTypes
|
||||||
import sushi.hardcore.droidfs.R
|
import sushi.hardcore.droidfs.R
|
||||||
|
import sushi.hardcore.droidfs.VolumeManagerApp
|
||||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||||
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
import sushi.hardcore.droidfs.util.IntentUtils
|
|
||||||
import sushi.hardcore.droidfs.util.PathUtils
|
import sushi.hardcore.droidfs.util.PathUtils
|
||||||
|
import sushi.hardcore.droidfs.util.finishOnClose
|
||||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
|
|
||||||
abstract class FileViewerActivity: BaseActivity() {
|
abstract class FileViewerActivity: BaseActivity() {
|
||||||
@ -30,9 +33,8 @@ abstract class FileViewerActivity: BaseActivity() {
|
|||||||
private lateinit var originalParentPath: String
|
private lateinit var originalParentPath: String
|
||||||
private lateinit var windowInsetsController: WindowInsetsControllerCompat
|
private lateinit var windowInsetsController: WindowInsetsControllerCompat
|
||||||
private var windowTypeMask = 0
|
private var windowTypeMask = 0
|
||||||
private var foldersFirst = true
|
protected val playlist = mutableListOf<ExplorerElement>()
|
||||||
private var wasMapped = false
|
private val playlistMutex = Mutex()
|
||||||
protected val mappedPlaylist = mutableListOf<ExplorerElement>()
|
|
||||||
protected var currentPlaylistIndex = -1
|
protected var currentPlaylistIndex = -1
|
||||||
private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
|
private val isLegacyFullscreen = Build.VERSION.SDK_INT <= Build.VERSION_CODES.R
|
||||||
|
|
||||||
@ -40,8 +42,10 @@ abstract class FileViewerActivity: BaseActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
filePath = intent.getStringExtra("path")!!
|
filePath = intent.getStringExtra("path")!!
|
||||||
originalParentPath = PathUtils.getParentPath(filePath)
|
originalParentPath = PathUtils.getParentPath(filePath)
|
||||||
encryptedVolume = IntentUtils.getParcelableExtra(intent, "volume")!!
|
encryptedVolume = (application as VolumeManagerApp).volumeManager.getVolume(
|
||||||
foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
intent.getIntExtra("volumeId", -1)
|
||||||
|
)!!
|
||||||
|
finishOnClose(encryptedVolume)
|
||||||
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
|
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
|
||||||
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
|
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
|
||||||
windowTypeMask = typeMask
|
windowTypeMask = typeMask
|
||||||
@ -126,58 +130,60 @@ abstract class FileViewerActivity: BaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun createPlaylist() {
|
protected suspend fun createPlaylist() {
|
||||||
if (!wasMapped){
|
playlistMutex.withLock {
|
||||||
encryptedVolume.recursiveMapFiles(originalParentPath)?.let { elements ->
|
if (currentPlaylistIndex != -1) {
|
||||||
for (e in elements) {
|
// playlist already initialized
|
||||||
if (e.isRegularFile) {
|
return
|
||||||
if (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath) {
|
|
||||||
mappedPlaylist.add(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val sortOrder = intent.getStringExtra("sortOrder") ?: "name"
|
withContext(Dispatchers.IO) {
|
||||||
ExplorerElement.sortBy(sortOrder, foldersFirst, mappedPlaylist)
|
if (sharedPrefs.getBoolean("map_folders", true)) {
|
||||||
//find current index
|
encryptedVolume.recursiveMapFiles(originalParentPath)
|
||||||
for ((i, e) in mappedPlaylist.withIndex()){
|
} else {
|
||||||
if (filePath == e.fullPath){
|
encryptedVolume.readDir(originalParentPath)
|
||||||
currentPlaylistIndex = i
|
}?.filterTo(playlist) { e ->
|
||||||
break
|
e.isRegularFile && (FileTypes.isExtensionType(getFileType(), e.name) || filePath == e.fullPath)
|
||||||
}
|
}
|
||||||
|
val sortOrder = intent.getStringExtra("sortOrder") ?: "name"
|
||||||
|
val foldersFirst = sharedPrefs.getBoolean("folders_first", true)
|
||||||
|
ExplorerElement.sortBy(sortOrder, foldersFirst, playlist)
|
||||||
|
currentPlaylistIndex = playlist.indexOfFirst { it.fullPath == filePath }
|
||||||
}
|
}
|
||||||
wasMapped = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun playlistNext(forward: Boolean) {
|
private fun updateCurrentItem() {
|
||||||
|
filePath = playlist[currentPlaylistIndex].fullPath
|
||||||
|
}
|
||||||
|
|
||||||
|
protected suspend fun playlistNext(forward: Boolean) {
|
||||||
createPlaylist()
|
createPlaylist()
|
||||||
currentPlaylistIndex = if (forward) {
|
currentPlaylistIndex = if (forward) {
|
||||||
(currentPlaylistIndex+1)%mappedPlaylist.size
|
(currentPlaylistIndex + 1).mod(playlist.size)
|
||||||
} else {
|
} else {
|
||||||
var x = (currentPlaylistIndex-1)%mappedPlaylist.size
|
(currentPlaylistIndex - 1).mod(playlist.size)
|
||||||
if (x < 0) {
|
|
||||||
x += mappedPlaylist.size
|
|
||||||
}
|
|
||||||
x
|
|
||||||
}
|
}
|
||||||
filePath = mappedPlaylist[currentPlaylistIndex].fullPath
|
updateCurrentItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun refreshPlaylist() {
|
protected suspend fun deleteCurrentFile(): Boolean {
|
||||||
mappedPlaylist.clear()
|
createPlaylist() // ensure we know the current position in the playlist
|
||||||
wasMapped = false
|
return if (encryptedVolume.deleteFile(filePath)) {
|
||||||
createPlaylist()
|
playlist.removeAt(currentPlaylistIndex)
|
||||||
|
if (playlist.size != 0) {
|
||||||
|
if (currentPlaylistIndex == playlist.size) {
|
||||||
|
// deleted the last element of the playlist, go back to the first
|
||||||
|
currentPlaylistIndex = 0
|
||||||
|
}
|
||||||
|
updateCurrentItem()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun goBackToExplorer() {
|
protected fun goBackToExplorer() {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
if (encryptedVolume.isClosed()) {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -12,10 +12,12 @@ import android.widget.Toast
|
|||||||
import androidx.activity.addCallback
|
import androidx.activity.addCallback
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.RequestBuilder
|
import com.bumptech.glide.RequestBuilder
|
||||||
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||||
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import sushi.hardcore.droidfs.Constants
|
import sushi.hardcore.droidfs.Constants
|
||||||
import sushi.hardcore.droidfs.R
|
import sushi.hardcore.droidfs.R
|
||||||
import sushi.hardcore.droidfs.databinding.ActivityImageViewerBinding
|
import sushi.hardcore.droidfs.databinding.ActivityImageViewerBinding
|
||||||
@ -105,22 +107,21 @@ class ImageViewer: FileViewerActivity() {
|
|||||||
.keepFullScreen()
|
.keepFullScreen()
|
||||||
.setTitle(R.string.warning)
|
.setTitle(R.string.warning)
|
||||||
.setPositiveButton(R.string.ok) { _, _ ->
|
.setPositiveButton(R.string.ok) { _, _ ->
|
||||||
createPlaylist() //be sure the playlist is created before deleting if there is only one image
|
lifecycleScope.launch {
|
||||||
if (encryptedVolume.deleteFile(filePath)) {
|
if (deleteCurrentFile()) {
|
||||||
playlistNext(true)
|
if (playlist.size == 0) { // no more image left
|
||||||
refreshPlaylist()
|
goBackToExplorer()
|
||||||
if (mappedPlaylist.size == 0) { //deleted all images of the playlist
|
} else {
|
||||||
goBackToExplorer()
|
loadImage(true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
loadImage(true)
|
CustomAlertDialogBuilder(this@ImageViewer, theme)
|
||||||
|
.keepFullScreen()
|
||||||
|
.setTitle(R.string.error)
|
||||||
|
.setMessage(getString(R.string.remove_failed, fileName))
|
||||||
|
.setPositiveButton(R.string.ok, null)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
CustomAlertDialogBuilder(this, theme)
|
|
||||||
.keepFullScreen()
|
|
||||||
.setTitle(R.string.error)
|
|
||||||
.setMessage(getString(R.string.remove_failed, fileName))
|
|
||||||
.setPositiveButton(R.string.ok, null)
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
@ -198,14 +199,16 @@ class ImageViewer: FileViewerActivity() {
|
|||||||
rotateImage()
|
rotateImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false){
|
private fun swipeImage(deltaX: Float, slideshowSwipe: Boolean = false) {
|
||||||
playlistNext(deltaX < 0)
|
lifecycleScope.launch {
|
||||||
loadImage(true)
|
playlistNext(deltaX < 0)
|
||||||
if (slideshowActive) {
|
loadImage(true)
|
||||||
if (!slideshowSwipe) { //reset slideshow delay if user swipes
|
if (slideshowActive) {
|
||||||
handler.removeCallbacks(slideshowNext)
|
if (!slideshowSwipe) { // reset slideshow delay if user swipes
|
||||||
|
handler.removeCallbacks(slideshowNext)
|
||||||
|
}
|
||||||
|
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
|
||||||
}
|
}
|
||||||
handler.postDelayed(slideshowNext, Constants.SLIDESHOW_DELAY)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,16 +1,24 @@
|
|||||||
package sushi.hardcore.droidfs.file_viewers
|
package sushi.hardcore.droidfs.file_viewers
|
||||||
|
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import com.google.android.exoplayer2.*
|
import androidx.annotation.OptIn
|
||||||
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import androidx.media3.common.MediaItem
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import androidx.media3.common.PlaybackException
|
||||||
import com.google.android.exoplayer2.video.VideoSize
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.VideoSize
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
|
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import sushi.hardcore.droidfs.Constants
|
import sushi.hardcore.droidfs.Constants
|
||||||
import sushi.hardcore.droidfs.R
|
import sushi.hardcore.droidfs.R
|
||||||
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
import sushi.hardcore.droidfs.widgets.CustomAlertDialogBuilder
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
abstract class MediaPlayer: FileViewerActivity() {
|
abstract class MediaPlayer: FileViewerActivity() {
|
||||||
private lateinit var player: ExoPlayer
|
private lateinit var player: ExoPlayer
|
||||||
|
|
||||||
@ -33,12 +41,16 @@ abstract class MediaPlayer: FileViewerActivity() {
|
|||||||
private fun initializePlayer(){
|
private fun initializePlayer(){
|
||||||
player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
|
player = ExoPlayer.Builder(this).setSeekForwardIncrementMs(5000).build()
|
||||||
bindPlayer(player)
|
bindPlayer(player)
|
||||||
createPlaylist()
|
player.addMediaSource(createMediaSource(filePath))
|
||||||
for (e in mappedPlaylist) {
|
lifecycleScope.launch {
|
||||||
player.addMediaSource(createMediaSource(e.fullPath))
|
createPlaylist()
|
||||||
|
playlist.forEachIndexed { index, e ->
|
||||||
|
if (index != currentPlaylistIndex) {
|
||||||
|
player.addMediaSource(index, createMediaSource(e.fullPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
player.repeatMode = Player.REPEAT_MODE_ALL
|
player.repeatMode = Player.REPEAT_MODE_ALL
|
||||||
player.seekToDefaultPosition(currentPlaylistIndex)
|
|
||||||
player.playWhenReady = true
|
player.playWhenReady = true
|
||||||
player.addListener(object : Player.Listener{
|
player.addListener(object : Player.Listener{
|
||||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||||
@ -61,9 +73,11 @@ abstract class MediaPlayer: FileViewerActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||||
if (player.repeatMode != Player.REPEAT_MODE_ONE) {
|
if (player.repeatMode != Player.REPEAT_MODE_ONE && currentPlaylistIndex != -1) {
|
||||||
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % mappedPlaylist.size)
|
lifecycleScope.launch {
|
||||||
refreshFileName()
|
playlistNext(player.currentMediaItemIndex == (currentPlaylistIndex + 1) % player.mediaItemCount)
|
||||||
|
refreshFileName()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -3,8 +3,8 @@ package sushi.hardcore.droidfs.file_viewers
|
|||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import com.google.android.exoplayer2.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import sushi.hardcore.droidfs.databinding.ActivityVideoPlayerBinding
|
import sushi.hardcore.droidfs.databinding.ActivityVideoPlayerBinding
|
||||||
|
|
||||||
class VideoPlayer: MediaPlayer() {
|
class VideoPlayer: MediaPlayer() {
|
||||||
@ -19,7 +19,7 @@ class VideoPlayer: MediaPlayer() {
|
|||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
applyNavigationBarMargin(binding.root)
|
applyNavigationBarMargin(binding.root)
|
||||||
binding.videoPlayer.doubleTapOverlay = binding.doubleTapOverlay
|
binding.videoPlayer.doubleTapOverlay = binding.doubleTapOverlay
|
||||||
binding.videoPlayer.setControllerVisibilityListener(StyledPlayerView.ControllerVisibilityListener { visibility ->
|
binding.videoPlayer.setControllerVisibilityListener(PlayerView.ControllerVisibilityListener { visibility ->
|
||||||
binding.topBar.visibility = visibility
|
binding.topBar.visibility = visibility
|
||||||
if (visibility == View.VISIBLE) {
|
if (visibility == View.VISIBLE) {
|
||||||
showPartialSystemUi()
|
showPartialSystemUi()
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package sushi.hardcore.droidfs.filesystems
|
package sushi.hardcore.droidfs.filesystems
|
||||||
|
|
||||||
import android.os.Parcel
|
|
||||||
import sushi.hardcore.droidfs.Constants
|
import sushi.hardcore.droidfs.Constants
|
||||||
import sushi.hardcore.droidfs.R
|
import sushi.hardcore.droidfs.R
|
||||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||||
@ -101,13 +100,6 @@ class CryfsVolume(private val fusePtr: Long): EncryptedVolume() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(parcel: Parcel) : this(parcel.readLong())
|
|
||||||
|
|
||||||
override fun writeToParcel(parcel: Parcel, flags: Int) = with(parcel) {
|
|
||||||
writeByte(CRYFS_VOLUME_TYPE)
|
|
||||||
writeLong(fusePtr)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun openFileReadMode(path: String): Long {
|
override fun openFileReadMode(path: String): Long {
|
||||||
return nativeOpen(fusePtr, path, 0)
|
return nativeOpen(fusePtr, path, 0)
|
||||||
}
|
}
|
||||||
|
@ -2,18 +2,21 @@ package sushi.hardcore.droidfs.filesystems
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Parcel
|
|
||||||
import android.os.Parcelable
|
|
||||||
import sushi.hardcore.droidfs.Constants
|
import sushi.hardcore.droidfs.Constants
|
||||||
import sushi.hardcore.droidfs.VolumeData
|
import sushi.hardcore.droidfs.VolumeData
|
||||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||||
import sushi.hardcore.droidfs.util.ObjRef
|
import sushi.hardcore.droidfs.util.ObjRef
|
||||||
|
import sushi.hardcore.droidfs.util.Observable
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
abstract class EncryptedVolume: Parcelable {
|
abstract class EncryptedVolume: Observable<EncryptedVolume.Observer>() {
|
||||||
|
|
||||||
|
interface Observer {
|
||||||
|
fun onClose()
|
||||||
|
}
|
||||||
|
|
||||||
class InitResult(
|
class InitResult(
|
||||||
val errorCode: Int,
|
val errorCode: Int,
|
||||||
@ -35,18 +38,6 @@ abstract class EncryptedVolume: Parcelable {
|
|||||||
const val GOCRYPTFS_VOLUME_TYPE: Byte = 0
|
const val GOCRYPTFS_VOLUME_TYPE: Byte = 0
|
||||||
const val CRYFS_VOLUME_TYPE: Byte = 1
|
const val CRYFS_VOLUME_TYPE: Byte = 1
|
||||||
|
|
||||||
@JvmField
|
|
||||||
val CREATOR = object : Parcelable.Creator<EncryptedVolume> {
|
|
||||||
override fun createFromParcel(parcel: Parcel): EncryptedVolume {
|
|
||||||
return when (parcel.readByte()) {
|
|
||||||
GOCRYPTFS_VOLUME_TYPE -> GocryptfsVolume(parcel)
|
|
||||||
CRYFS_VOLUME_TYPE -> CryfsVolume(parcel)
|
|
||||||
else -> throw invalidVolumeType()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
override fun newArray(size: Int) = arrayOfNulls<EncryptedVolume>(size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the type of a volume.
|
* Get the type of a volume.
|
||||||
*
|
*
|
||||||
@ -92,8 +83,6 @@ abstract class EncryptedVolume: Parcelable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun describeContents() = 0
|
|
||||||
|
|
||||||
abstract fun openFileReadMode(path: String): Long
|
abstract fun openFileReadMode(path: String): Long
|
||||||
abstract fun openFileWriteMode(path: String): Long
|
abstract fun openFileWriteMode(path: String): Long
|
||||||
abstract fun read(fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int
|
abstract fun read(fileHandle: Long, fileOffset: Long, buffer: ByteArray, dstOffset: Long, length: Long): Int
|
||||||
@ -107,9 +96,14 @@ abstract class EncryptedVolume: Parcelable {
|
|||||||
abstract fun rmdir(path: String): Boolean
|
abstract fun rmdir(path: String): Boolean
|
||||||
abstract fun getAttr(path: String): Stat?
|
abstract fun getAttr(path: String): Stat?
|
||||||
abstract fun rename(srcPath: String, dstPath: String): Boolean
|
abstract fun rename(srcPath: String, dstPath: String): Boolean
|
||||||
abstract fun close()
|
protected abstract fun close()
|
||||||
abstract fun isClosed(): Boolean
|
abstract fun isClosed(): Boolean
|
||||||
|
|
||||||
|
fun closeVolume() {
|
||||||
|
observers.forEach { it.onClose() }
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
fun pathExists(path: String): Boolean {
|
fun pathExists(path: String): Boolean {
|
||||||
return getAttr(path) != null
|
return getAttr(path) != null
|
||||||
}
|
}
|
||||||
@ -160,7 +154,6 @@ abstract class EncryptedVolume: Parcelable {
|
|||||||
if (written == length) {
|
if (written == length) {
|
||||||
offset += written
|
offset += written
|
||||||
} else {
|
} else {
|
||||||
inputStream.close()
|
|
||||||
success = false
|
success = false
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package sushi.hardcore.droidfs.filesystems
|
package sushi.hardcore.droidfs.filesystems
|
||||||
|
|
||||||
import android.os.Parcel
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import sushi.hardcore.droidfs.R
|
import sushi.hardcore.droidfs.R
|
||||||
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
import sushi.hardcore.droidfs.explorers.ExplorerElement
|
||||||
@ -100,8 +99,6 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(parcel: Parcel) : this(parcel.readInt())
|
|
||||||
|
|
||||||
override fun openFileReadMode(path: String): Long {
|
override fun openFileReadMode(path: String): Long {
|
||||||
return native_open_read_mode(sessionID, path).toLong()
|
return native_open_read_mode(sessionID, path).toLong()
|
||||||
}
|
}
|
||||||
@ -122,11 +119,6 @@ class GocryptfsVolume(private val sessionID: Int): EncryptedVolume() {
|
|||||||
return native_get_attr(sessionID, path)
|
return native_get_attr(sessionID, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun writeToParcel(parcel: Parcel, flags: Int) = with(parcel) {
|
|
||||||
writeByte(GOCRYPTFS_VOLUME_TYPE)
|
|
||||||
writeInt(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
native_close(sessionID)
|
native_close(sessionID)
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
package sushi.hardcore.droidfs.filesystems
|
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 {
|
companion object {
|
||||||
|
private const val S_IFMT = 0xF000
|
||||||
const val S_IFDIR = 0x4000
|
const val S_IFDIR = 0x4000
|
||||||
const val S_IFREG = 0x8000
|
const val S_IFREG = 0x8000
|
||||||
const val S_IFLNK = 0xA000
|
const val S_IFLNK = 0xA000
|
||||||
const val PARENT_FOLDER_TYPE = -1
|
const val PARENT_FOLDER_TYPE = 0xE000
|
||||||
|
|
||||||
fun parentFolderStat(): Stat {
|
fun parentFolderStat(): Stat {
|
||||||
return Stat(PARENT_FOLDER_TYPE, -1, -1)
|
return Stat(PARENT_FOLDER_TYPE, -1, -1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val type = mode and S_IFMT
|
||||||
}
|
}
|
110
app/src/main/java/sushi/hardcore/droidfs/util/AndroidUtils.kt
Normal file
110
app/src/main/java/sushi/hardcore/droidfs/util/AndroidUtils.kt
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package sushi.hardcore.droidfs.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
object AndroidUtils {
|
||||||
|
fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean {
|
||||||
|
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
for (service in manager.getRunningServices(Int.MAX_VALUE)) {
|
||||||
|
if (serviceClass.name == service.service.className) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [Manifest.permission.POST_NOTIFICATIONS] permission helper.
|
||||||
|
*
|
||||||
|
* Must be initialized before [Activity.onCreate] finishes.
|
||||||
|
*/
|
||||||
|
class NotificationPermissionHelper<out A: AppCompatActivity>(val activity: A) {
|
||||||
|
private var listener: ((Boolean) -> Unit)? = null
|
||||||
|
private val launcher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
listener?.invoke(granted)
|
||||||
|
listener = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for notification permission if required and run the provided callback.
|
||||||
|
*
|
||||||
|
* The callback is run as soon as the user dismisses the permission dialog,
|
||||||
|
* no matter if the permission has been granted or not.
|
||||||
|
*
|
||||||
|
* If this function is called again before the user answered the dialog from the
|
||||||
|
* previous call, the previous callback won't be triggered.
|
||||||
|
*
|
||||||
|
* @param onDialogDismiss argument set to `true` if the permission is granted or
|
||||||
|
* not required, `false` otherwise
|
||||||
|
*/
|
||||||
|
fun askAndRun(onDialogDismiss: (Boolean) -> Unit) {
|
||||||
|
assert(listener == null)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
listener = onDialogDismiss
|
||||||
|
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDialogDismiss(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Property delegate mirroring the state of a boolean value in shared preferences.
|
||||||
|
*
|
||||||
|
* [init] **must** be called before accessing the delegated property.
|
||||||
|
*/
|
||||||
|
class LiveBooleanPreference(
|
||||||
|
private val key: String,
|
||||||
|
private val defaultValue: Boolean = false,
|
||||||
|
private val onChange: ((value: Boolean) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
private lateinit var sharedPreferences: SharedPreferences
|
||||||
|
private var value = defaultValue
|
||||||
|
private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
|
if (key == this.key) {
|
||||||
|
reload()
|
||||||
|
onChange?.invoke(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init(context: Context) = init(PreferenceManager.getDefaultSharedPreferences(context))
|
||||||
|
|
||||||
|
fun init(sharedPreferences: SharedPreferences) {
|
||||||
|
this.sharedPreferences = sharedPreferences
|
||||||
|
reload()
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reload() {
|
||||||
|
value = sharedPreferences.getBoolean(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun getValue(thisRef: Any, property: KProperty<*>) = value
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun init(context: Context, vararg liveBooleanPreferences: LiveBooleanPreference) {
|
||||||
|
init(PreferenceManager.getDefaultSharedPreferences(context), *liveBooleanPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init(sharedPreferences: SharedPreferences, vararg liveBooleanPreferences: LiveBooleanPreference) {
|
||||||
|
for (i in liveBooleanPreferences) {
|
||||||
|
i.init(sharedPreferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -13,4 +13,11 @@ object Compat {
|
|||||||
bundle.getParcelable(name)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
21
app/src/main/java/sushi/hardcore/droidfs/util/Observable.kt
Normal file
21
app/src/main/java/sushi/hardcore/droidfs/util/Observable.kt
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package sushi.hardcore.droidfs.util
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
|
||||||
|
|
||||||
|
abstract class Observable<T> {
|
||||||
|
protected val observers = mutableListOf<T>()
|
||||||
|
|
||||||
|
fun observe(observer: T) {
|
||||||
|
observers.add(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Activity.finishOnClose(encryptedVolume: EncryptedVolume) {
|
||||||
|
encryptedVolume.observe(object : EncryptedVolume.Observer {
|
||||||
|
override fun onClose() {
|
||||||
|
finish()
|
||||||
|
// no need to remove observer as the EncryptedVolume will be destroyed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
@ -3,6 +3,7 @@ package sushi.hardcore.droidfs.util
|
|||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
@ -111,24 +112,27 @@ object PathUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d(PATH_RESOLVER_TAG, "getExternalFilesDirs failed")
|
Log.d(PATH_RESOLVER_TAG, "getExternalFilesDirs failed")
|
||||||
try {
|
// Don't risk to be killed by SELinux on newer Android versions
|
||||||
val process = ProcessBuilder("mount").redirectErrorStream(true).start().apply { waitFor() }
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||||
process.inputStream.readBytes().decodeToString().split("\n").forEach { line ->
|
try {
|
||||||
if (line.startsWith("/dev/block/vold")) {
|
val process = ProcessBuilder("mount").redirectErrorStream(true).start().apply { waitFor() }
|
||||||
Log.d(PATH_RESOLVER_TAG, "mount: $line")
|
process.inputStream.readBytes().decodeToString().split("\n").forEach { line ->
|
||||||
val fields = line.split(" ")
|
if (line.startsWith("/dev/block/vold")) {
|
||||||
if (fields.size >= 3) {
|
Log.d(PATH_RESOLVER_TAG, "mount: $line")
|
||||||
val path = fields[2]
|
val fields = line.split(" ")
|
||||||
if (File(path).name == name) {
|
if (fields.size >= 3) {
|
||||||
return path
|
val path = fields[2]
|
||||||
|
if (File(path).name == name) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
Log.d(PATH_RESOLVER_TAG, "mount processing failed")
|
||||||
e.printStackTrace()
|
|
||||||
}
|
}
|
||||||
Log.d(PATH_RESOLVER_TAG, "mount processing failed")
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
42
app/src/main/java/sushi/hardcore/droidfs/util/UIUtils.kt
Normal file
42
app/src/main/java/sushi/hardcore/droidfs/util/UIUtils.kt
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
@ -3,7 +3,7 @@ package sushi.hardcore.droidfs.util
|
|||||||
import java.lang.Integer.max
|
import java.lang.Integer.max
|
||||||
|
|
||||||
class Version(inputVersion: String) : Comparable<Version> {
|
class Version(inputVersion: String) : Comparable<Version> {
|
||||||
private var version: String
|
private val version: String
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val regex = "[0-9]+(\\.[0-9]+)*".toRegex()
|
val regex = "[0-9]+(\\.[0-9]+)*".toRegex()
|
||||||
@ -24,4 +24,6 @@ class Version(inputVersion: String) : Comparable<Version> {
|
|||||||
}
|
}
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun toString() = version
|
||||||
}
|
}
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -185,14 +185,14 @@ internal class CircleClipTapView(context: Context, attrs: AttributeSet): View(co
|
|||||||
updatePathShape()
|
updatePathShape()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDraw(canvas: Canvas?) {
|
override fun onDraw(canvas: Canvas) {
|
||||||
super.onDraw(canvas)
|
super.onDraw(canvas)
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
canvas?.clipPath(shapePath)
|
canvas.clipPath(shapePath)
|
||||||
canvas?.drawPath(shapePath, backgroundPaint)
|
canvas.drawPath(shapePath, backgroundPaint)
|
||||||
|
|
||||||
// Circle
|
// Circle
|
||||||
canvas?.drawCircle(cX, cY, currentRadius, circlePaint)
|
canvas.drawCircle(cX, cY, currentRadius, circlePaint)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -2,29 +2,19 @@ package sushi.hardcore.droidfs.widgets
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.GestureDetector
|
import android.view.GestureDetector
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import androidx.media3.ui.PlayerView
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import androidx.core.view.GestureDetectorCompat
|
|
||||||
import androidx.core.view.updateLayoutParams
|
|
||||||
import androidx.core.view.updatePadding
|
|
||||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
|
||||||
import sushi.hardcore.droidfs.R
|
|
||||||
|
|
||||||
class DoubleTapPlayerView @JvmOverloads constructor(
|
class DoubleTapPlayerView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
defStyleAttr: Int = 0
|
defStyleAttr: Int = 0
|
||||||
) : StyledPlayerView(context, attrs, defStyleAttr) {
|
) : PlayerView(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val SEEK_SECONDS = 10
|
const val SEEK_SECONDS = 10
|
||||||
@ -75,22 +65,7 @@ class DoubleTapPlayerView @JvmOverloads constructor(
|
|||||||
handler.postDelayed(stopDoubleTap, 700)
|
handler.postDelayed(stopDoubleTap, 700)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val gestureDetector = GestureDetectorCompat(context, gestureListener)
|
private val gestureDetector = GestureDetector(context, gestureListener)
|
||||||
private val density by lazy {
|
|
||||||
context.resources.displayMetrics.density
|
|
||||||
}
|
|
||||||
private val originalExoIconPaddingBottom by lazy {
|
|
||||||
resources.getDimension(R.dimen.exo_icon_padding_bottom)
|
|
||||||
}
|
|
||||||
private val originalExoIconSize by lazy {
|
|
||||||
resources.getDimension(R.dimen.exo_icon_size)
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
|
||||||
handleOrientationChange(Configuration.ORIENTATION_LANDSCAPE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("ClickableViewAccessibility")
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
@ -135,35 +110,4 @@ class DoubleTapPlayerView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateButtonSize(orientation: Int) {
|
|
||||||
val size = (if (orientation == Configuration.ORIENTATION_LANDSCAPE) 45*density else originalExoIconSize).toInt()
|
|
||||||
listOf(R.id.exo_prev, R.id.exo_rew_with_amount, R.id.exo_play_pause, R.id.exo_ffwd_with_amount, R.id.exo_next).forEach {
|
|
||||||
findViewById<View>(it).updateLayoutParams {
|
|
||||||
width = size
|
|
||||||
height = size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// fix text vertical alignment inside icons
|
|
||||||
val paddingBottom = (if (orientation == Configuration.ORIENTATION_LANDSCAPE) 15*density else originalExoIconPaddingBottom).toInt()
|
|
||||||
listOf(R.id.exo_rew_with_amount, R.id.exo_ffwd_with_amount).forEach {
|
|
||||||
findViewById<Button>(it).updatePadding(bottom = paddingBottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleOrientationChange(orientation: Int) {
|
|
||||||
val centerControls = findViewById<LinearLayout>(R.id.exo_center_controls)
|
|
||||||
(centerControls.parent as ViewGroup).removeView(centerControls)
|
|
||||||
findViewById<FrameLayout>(if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
|
||||||
R.id.center_controls_bar
|
|
||||||
} else {
|
|
||||||
R.id.center_controls_external
|
|
||||||
}).addView(centerControls)
|
|
||||||
updateButtonSize(orientation)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
||||||
super.onConfigurationChanged(newConfig)
|
|
||||||
handleOrientationChange(newConfig.orientation)
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -196,6 +196,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1list_1dir(JNIEnv
|
|||||||
jint sessionID, jstring jplain_dir) {
|
jint sessionID, jstring jplain_dir) {
|
||||||
const char* plain_dir = (*env)->GetStringUTFChars(env, jplain_dir, NULL);
|
const char* plain_dir = (*env)->GetStringUTFChars(env, jplain_dir, NULL);
|
||||||
const size_t plain_dir_len = strlen(plain_dir);
|
const size_t plain_dir_len = strlen(plain_dir);
|
||||||
|
const char append_slash = plain_dir[plain_dir_len-1] != '/';
|
||||||
GoString go_plain_dir = {plain_dir, plain_dir_len};
|
GoString go_plain_dir = {plain_dir, plain_dir_len};
|
||||||
|
|
||||||
struct gcf_list_dir_return elements = gcf_list_dir(sessionID, go_plain_dir);
|
struct gcf_list_dir_return elements = gcf_list_dir(sessionID, go_plain_dir);
|
||||||
@ -216,7 +217,7 @@ Java_sushi_hardcore_droidfs_filesystems_GocryptfsVolume_native_1list_1dir(JNIEnv
|
|||||||
|
|
||||||
char* fullPath = malloc(sizeof(char) * (plain_dir_len + nameLen + 2));
|
char* fullPath = malloc(sizeof(char) * (plain_dir_len + nameLen + 2));
|
||||||
strcpy(fullPath, plain_dir);
|
strcpy(fullPath, plain_dir);
|
||||||
if (plain_dir[-2] != '/') {
|
if (append_slash) {
|
||||||
strcat(fullPath, "/");
|
strcat(fullPath, "/");
|
||||||
}
|
}
|
||||||
strcat(fullPath, name);
|
strcat(fullPath, name);
|
||||||
|
@ -40,7 +40,7 @@ struct Muxer {
|
|||||||
jmethodID seek_method_id;
|
jmethodID seek_method_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
int write_packet(void* opaque, uint8_t* buff, int buff_size) {
|
int write_packet(void* opaque, const uint8_t* buff, int buff_size) {
|
||||||
struct Muxer* muxer = opaque;
|
struct Muxer* muxer = opaque;
|
||||||
JNIEnv *env;
|
JNIEnv *env;
|
||||||
(*muxer->jvm)->GetEnv(muxer->jvm, (void **) &env, JNI_VERSION_1_6);
|
(*muxer->jvm)->GetEnv(muxer->jvm, (void **) &env, JNI_VERSION_1_6);
|
||||||
@ -108,8 +108,8 @@ Java_sushi_hardcore_droidfs_video_1recording_FFmpegMuxer_addVideoTrack(JNIEnv *e
|
|||||||
stream->codecpar->height = height;
|
stream->codecpar->height = height;
|
||||||
stream->codecpar->format = AV_PIX_FMT_YUVJ420P;
|
stream->codecpar->format = AV_PIX_FMT_YUVJ420P;
|
||||||
stream->time_base = (AVRational) {1, frame_rate};
|
stream->time_base = (AVRational) {1, frame_rate};
|
||||||
uint8_t* matrix = av_stream_new_side_data(stream, AV_PKT_DATA_DISPLAYMATRIX, sizeof(int32_t) * 9);
|
AVPacketSideData *side_data_packet = av_packet_side_data_new(&stream->codecpar->coded_side_data, &stream->codecpar->nb_coded_side_data, AV_PKT_DATA_DISPLAYMATRIX, sizeof(int32_t) * 9, 0);
|
||||||
av_display_rotation_set((int32_t *) matrix, orientation_hint);
|
av_display_rotation_set((int32_t *) side_data_packet->data, orientation_hint);
|
||||||
return stream->index;
|
return stream->index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,24 +1,30 @@
|
|||||||
|
#include <errno.h>
|
||||||
|
#include <string.h>
|
||||||
#include <sys/mman.h>
|
#include <sys/mman.h>
|
||||||
#include <sys/syscall.h>
|
#include <sys/syscall.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <jni.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));
|
||||||
|
}
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT jint JNICALL
|
JNIEXPORT jint JNICALL
|
||||||
Java_sushi_hardcore_droidfs_MemFile_00024Companion_createMemFile(JNIEnv *env, jobject thiz, jstring jname,
|
Java_sushi_hardcore_droidfs_MemFile_00024Companion_createMemFile(JNIEnv *env, jobject thiz, jstring jname,
|
||||||
jlong size) {
|
jlong size) {
|
||||||
const char* name = env->GetStringUTFChars(jname, nullptr);
|
const char* name = (*env)->GetStringUTFChars(env, jname, NULL);
|
||||||
int fd = syscall(SYS_memfd_create, name, MFD_CLOEXEC);
|
int fd = syscall(SYS_memfd_create, name, MFD_CLOEXEC);
|
||||||
if (fd < 0) return fd;
|
if (fd < 0) {
|
||||||
|
log_err("memfd_create");
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
if (ftruncate64(fd, size) == -1) {
|
if (ftruncate64(fd, size) == -1) {
|
||||||
|
log_err("ftruncate64");
|
||||||
close(fd);
|
close(fd);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
return fd;
|
return fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT void JNICALL
|
|
||||||
Java_sushi_hardcore_droidfs_MemFile_close(JNIEnv *env, jobject thiz, jint fd) {
|
|
||||||
close(fd);
|
|
||||||
}
|
|
BIN
app/src/main/res/drawable-hdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-hdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 819 B |
BIN
app/src/main/res/drawable-mdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-mdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 540 B |
BIN
app/src/main/res/drawable-xhdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-xhdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 KiB |
BIN
app/src/main/res/drawable-xxhdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-xxhdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/icon_document_provider.png
Normal file
BIN
app/src/main/res/drawable-xxxhdpi/icon_document_provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
5
app/src/main/res/drawable/icon_debug.xml
Normal file
5
app/src/main/res/drawable/icon_debug.xml
Normal 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>
|
5
app/src/main/res/drawable/icon_edit.xml
Normal file
5
app/src/main/res/drawable/icon_edit.xml
Normal 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>
|
@ -1,5 +1,5 @@
|
|||||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
<vector android:height="24dp"
|
||||||
android:viewportHeight="24" android:viewportWidth="24"
|
android:viewportHeight="24" android:viewportWidth="24"
|
||||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
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>
|
</vector>
|
||||||
|
9
app/src/main/res/drawable/round_button_background.xml
Normal file
9
app/src/main/res/drawable/round_button_background.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="?attr/buttonBackgroundColor"/>
|
||||||
|
<corners android:radius="50dp"/>
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
@ -15,11 +15,11 @@
|
|||||||
android:textSize="@dimen/title_text_size"
|
android:textSize="@dimen/title_text_size"
|
||||||
android:padding="10dp"/>
|
android:padding="10dp"/>
|
||||||
|
|
||||||
<com.google.android.exoplayer2.ui.StyledPlayerControlView
|
<androidx.media3.ui.PlayerControlView
|
||||||
android:id="@+id/audio_controller"
|
android:id="@+id/audio_controller"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
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_timeout="0"
|
||||||
app:show_shuffle_button="true"
|
app:show_shuffle_button="true"
|
||||||
app:repeat_toggle_modes="all|one"/>
|
app:repeat_toggle_modes="all|one"/>
|
||||||
|
20
app/src/main/res/layout/activity_logcat.xml
Normal file
20
app/src/main/res/layout/activity_logcat.xml
Normal 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>
|
@ -26,20 +26,20 @@
|
|||||||
android:layout_gravity="center_horizontal"
|
android:layout_gravity="center_horizontal"
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:layoutDirection="ltr"
|
|
||||||
android:padding="@dimen/exo_styled_controls_padding"
|
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"
|
<ImageButton android:id="@id/exo_prev"
|
||||||
style="@style/ExoStyledControls.Button.Center.Previous"/>
|
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"
|
<ImageButton android:id="@id/exo_play_pause"
|
||||||
style="@style/ExoStyledControls.Button.Center.PlayPause"/>
|
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"
|
<ImageButton android:id="@id/exo_next"
|
||||||
style="@style/ExoStyledControls.Button.Center.Next"/>
|
style="@style/ExoStyledControls.Button.Center.Next"/>
|
@ -1,30 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@id/exo_center_controls"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:background="@android:color/transparent"
|
|
||||||
android:gravity="center"
|
|
||||||
android:layoutDirection="ltr"
|
|
||||||
android:padding="@dimen/exo_styled_controls_padding"
|
|
||||||
android:clipToPadding="false">
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_prev"
|
|
||||||
style="@style/ExoStyledControls.Button.Center.Previous"/>
|
|
||||||
|
|
||||||
<include layout="@layout/exo_styled_player_control_rewind_button" />
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_play_pause"
|
|
||||||
style="@style/ExoStyledControls.Button.Center.PlayPause"/>
|
|
||||||
|
|
||||||
<include layout="@layout/exo_styled_player_control_ffwd_button" />
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_next"
|
|
||||||
style="@style/ExoStyledControls.Button.Center.Next"/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</merge>
|
|
@ -1,148 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Copyright 2020 The Android Open Source Project
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
-->
|
|
||||||
<merge xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<!-- 0dp dimensions are used to prevent this view from influencing the size of
|
|
||||||
the parent view if it uses "wrap_content". It is expanded to occupy the
|
|
||||||
entirety of the parent in code, after the parent's size has been
|
|
||||||
determined. See: https://github.com/google/ExoPlayer/issues/8726.
|
|
||||||
-->
|
|
||||||
<View android:id="@id/exo_controls_background"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="0dp"
|
|
||||||
android:background="@color/exo_black_opacity_60"/>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="bottom"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/center_controls_external"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="bottom|center_horizontal">
|
|
||||||
|
|
||||||
<include layout="@layout/exo_center_controls"/>
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout android:id="@id/exo_bottom_bar"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="@dimen/exo_styled_bottom_bar_height"
|
|
||||||
android:background="@color/exo_bottom_bar_background"
|
|
||||||
android:layoutDirection="ltr">
|
|
||||||
|
|
||||||
<LinearLayout android:id="@id/exo_time"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingStart="@dimen/exo_styled_bottom_bar_time_padding"
|
|
||||||
android:paddingEnd="@dimen/exo_styled_bottom_bar_time_padding"
|
|
||||||
android:paddingLeft="@dimen/exo_styled_bottom_bar_time_padding"
|
|
||||||
android:paddingRight="@dimen/exo_styled_bottom_bar_time_padding"
|
|
||||||
android:layout_gravity="center_vertical|start"
|
|
||||||
android:layoutDirection="ltr">
|
|
||||||
|
|
||||||
<TextView android:id="@id/exo_position"
|
|
||||||
style="@style/ExoStyledControls.TimeText.Position"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
style="@style/ExoStyledControls.TimeText.Separator"/>
|
|
||||||
|
|
||||||
<TextView android:id="@id/exo_duration"
|
|
||||||
style="@style/ExoStyledControls.TimeText.Duration"/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:id="@+id/center_controls_bar"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"/>
|
|
||||||
|
|
||||||
<LinearLayout android:id="@id/exo_basic_controls"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center_vertical|end"
|
|
||||||
android:layoutDirection="ltr">
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_vr"
|
|
||||||
style="@style/ExoStyledControls.Button.Bottom.VR"/>
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_shuffle"
|
|
||||||
style="@style/ExoStyledControls.Button.Bottom.Shuffle"/>
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_repeat_toggle"
|
|
||||||
style="@style/ExoStyledControls.Button.Bottom.RepeatToggle"/>
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_subtitle"
|
|
||||||
style="@style/ExoStyledControls.Button.Bottom.CC"/>
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_settings"
|
|
||||||
style="@style/ExoStyledControls.Button.Bottom.Settings"/>
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_fullscreen"
|
|
||||||
style="@style/ExoStyledControls.Button.Bottom.FullScreen"/>
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_overflow_show"
|
|
||||||
style="@style/ExoStyledControls.Button.Bottom.OverflowShow"/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<HorizontalScrollView android:id="@id/exo_extra_controls_scroll_view"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center_vertical|end"
|
|
||||||
android:visibility="invisible">
|
|
||||||
|
|
||||||
<LinearLayout android:id="@id/exo_extra_controls"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layoutDirection="ltr">
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_overflow_hide"
|
|
||||||
style="@style/ExoStyledControls.Button.Bottom.OverflowHide"/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</HorizontalScrollView>
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<View android:id="@id/exo_progress_placeholder"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="@dimen/exo_styled_progress_layout_height"
|
|
||||||
android:layout_gravity="bottom"
|
|
||||||
android:layout_marginBottom="@dimen/exo_styled_progress_margin_bottom"/>
|
|
||||||
|
|
||||||
<LinearLayout android:id="@id/exo_minimal_controls"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="bottom|end"
|
|
||||||
android:layout_marginBottom="@dimen/exo_styled_minimal_controls_margin_bottom"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:layoutDirection="ltr">
|
|
||||||
|
|
||||||
<ImageButton android:id="@id/exo_minimal_fullscreen"
|
|
||||||
style="@style/ExoStyledControls.Button.Bottom.FullScreen"/>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</merge>
|
|
@ -10,6 +10,12 @@
|
|||||||
android:text="@string/dir_empty"
|
android:text="@string/dir_empty"
|
||||||
android:visibility="gone"/>
|
android:visibility="gone"/>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/loader"
|
||||||
|
android:layout_centerInParent="true"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"/>
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/refresher"
|
android:id="@+id/refresher"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
39
app/src/main/res/layout/file_system_radio.xml
Normal file
39
app/src/main/res/layout/file_system_radio.xml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:paddingVertical="@dimen/selectable_row_vertical_padding">
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radio"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:layout_marginStart="30dp"
|
||||||
|
android:layout_marginEnd="10dp"/>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_toEndOf="@+id/radio"
|
||||||
|
android:layout_alignParentEnd="true">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/title"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="@dimen/title_text_size"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/details"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/textColorSecondary"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</RelativeLayout>
|
@ -3,25 +3,25 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical">
|
||||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/volume_type_label"/>
|
android:text="@string/volume_type_label"
|
||||||
|
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||||
|
|
||||||
<Spinner
|
<RadioGroup
|
||||||
android:id="@+id/spinner_volume_type"
|
android:id="@+id/radio_group_filesystems"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="center_horizontal"
|
android:layout_marginVertical="10dp"/>
|
||||||
android:layout_marginVertical="@dimen/volume_operation_vertical_gap"/>
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/password_label"/>
|
android:text="@string/password_label"
|
||||||
|
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||||
|
|
||||||
<EditText
|
<EditText
|
||||||
android:id="@+id/edit_password"
|
android:id="@+id/edit_password"
|
||||||
@ -30,13 +30,15 @@
|
|||||||
android:inputType="textPassword"
|
android:inputType="textPassword"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:autofillHints="password"
|
android:autofillHints="password"
|
||||||
android:hint="@string/password"/>
|
android:hint="@string/password"
|
||||||
|
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/password_confirmation_label"
|
android:text="@string/password_confirmation_label"
|
||||||
android:layout_marginTop="@dimen/volume_operation_vertical_gap"/>
|
android:layout_marginTop="@dimen/volume_operation_vertical_gap"
|
||||||
|
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||||
|
|
||||||
<EditText
|
<EditText
|
||||||
android:id="@+id/edit_password_confirm"
|
android:id="@+id/edit_password_confirm"
|
||||||
@ -45,11 +47,13 @@
|
|||||||
android:inputType="textPassword"
|
android:inputType="textPassword"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:autofillHints="password"
|
android:autofillHints="password"
|
||||||
android:hint="@string/password_confirmation_hint"/>
|
android:hint="@string/password_confirmation_hint"
|
||||||
|
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"/>
|
||||||
|
|
||||||
<RelativeLayout
|
<RelativeLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||||
android:layout_marginVertical="@dimen/volume_operation_vertical_gap">
|
android:layout_marginVertical="@dimen/volume_operation_vertical_gap">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:background="?attr/selectableItemBackground"
|
android:background="?attr/selectableItemBackground"
|
||||||
android:paddingHorizontal="@dimen/volume_operation_horizontal_gap"
|
android:paddingHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||||
|
android:paddingVertical="@dimen/selectable_row_vertical_padding"
|
||||||
android:layout_marginBottom="@dimen/volume_operation_vertical_gap">
|
android:layout_marginBottom="@dimen/volume_operation_vertical_gap">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
@ -54,44 +55,24 @@
|
|||||||
|
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
|
android:id="@+id/text_label"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||||
|
android:text="@string/volume_path_label"
|
||||||
|
android:layout_marginBottom="10dp"/>
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/edit_volume_name"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap">
|
android:hint="@string/volume_path_hint"
|
||||||
|
android:importantForAutofill="no"
|
||||||
<TextView
|
android:inputType="textNoSuggestions"
|
||||||
android:id="@+id/text_label"
|
android:maxLines="1"
|
||||||
android:layout_width="wrap_content"
|
android:visibility="gone" />
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/volume_path_label"/>
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content">
|
|
||||||
|
|
||||||
<EditText
|
|
||||||
android:id="@+id/edit_volume_name"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="0.5"
|
|
||||||
android:inputType="text"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:importantForAutofill="no"
|
|
||||||
android:hint="@string/volume_path_hint"/>
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/button_pick_directory"
|
|
||||||
android:layout_width="@dimen/image_button_size"
|
|
||||||
android:layout_height="@dimen/image_button_size"
|
|
||||||
android:scaleType="fitCenter"
|
|
||||||
android:background="#00000000"
|
|
||||||
android:src="@drawable/icon_folder"
|
|
||||||
android:contentDescription="@string/pick_directory" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_warning"
|
android:id="@+id/text_warning"
|
||||||
@ -101,12 +82,40 @@
|
|||||||
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
android:layout_marginHorizontal="@dimen/volume_operation_horizontal_gap"
|
||||||
android:visibility="gone"/>
|
android:visibility="gone"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/button_pick_directory"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
style="@style/RoundButton"
|
||||||
|
android:drawableStart="@drawable/icon_folder_search"
|
||||||
|
android:text="@string/pick_directory"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:layout_marginTop="10dp"
|
||||||
|
android:layout_marginBottom="10dp"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_or"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:text="@string/or"
|
||||||
|
android:layout_marginBottom="10dp"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/button_enter_path"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
style="@style/RoundButton"
|
||||||
|
android:drawableStart="@drawable/icon_edit"
|
||||||
|
android:text="@string/enter_volume_path"
|
||||||
|
android:layout_gravity="center_horizontal"/>
|
||||||
|
|
||||||
<androidx.appcompat.widget.SwitchCompat
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
android:id="@+id/switch_remember"
|
android:id="@+id/switch_remember"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/remember_volume"
|
android:text="@string/remember_volume"
|
||||||
android:checked="true"
|
android:checked="true"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
android:layout_gravity="center"/>
|
android:layout_gravity="center"/>
|
||||||
|
|
||||||
<androidx.appcompat.widget.AppCompatButton
|
<androidx.appcompat.widget.AppCompatButton
|
||||||
@ -116,6 +125,7 @@
|
|||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:layout_marginHorizontal="@dimen/volume_operation_button_horizontal_margin"
|
android:layout_marginHorizontal="@dimen/volume_operation_button_horizontal_margin"
|
||||||
android:layout_marginTop="@dimen/volume_operation_vertical_gap"
|
android:layout_marginTop="@dimen/volume_operation_vertical_gap"
|
||||||
|
android:visibility="gone"
|
||||||
android:text="@string/create_volume" />
|
android:text="@string/create_volume" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user