Compare commits

...

72 Commits

Author SHA1 Message Date
4aa211bca4
Fix gocryptfs_jni.c invalid string access 2024-08-22 23:01:35 +02:00
0a1406769b
Fix ABI versionCode offsets 2024-07-26 13:36:45 +02:00
a62f32e364
Update README.md & TODO.md 2024-07-25 17:18:29 +02:00
f865c864a2
Add changelog & Update screenshots & description 2024-07-25 17:16:57 +02:00
e804059b23
Add Hebrew translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-07-25 16:59:30 +02:00
solokot
bb821d5f41
Update Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-07-25 13:15:25 +02:00
6c0e20c68e
Disable usfExpose when disabling usfBackground 2024-07-24 17:57:20 +02:00
e9b67bd9c4
Update dependencies 2024-07-22 21:19:12 +02:00
c0dcaed8d2
Improve volume adding UI 2024-07-22 21:06:29 +02:00
85e24921fa
Replace file systems dropdown with radio buttons 2024-07-18 23:50:05 +02:00
15f288be11
Auto gocryptfs cipher by default & Fix FileOperationsService notification permission request 2024-07-18 23:50:04 +02:00
bb49501403
OpenSSL & FFmpeg as submodules & Different versionCode for each ABI 2024-07-18 23:49:51 +02:00
33d565bf22
KeepAlive foreground service 2024-07-16 15:03:44 +02:00
52a29b034c
Target Android 13 & Make FileOperationService a foreground service 2024-06-15 16:39:40 +02:00
d44601f69f
Restore upstream video player controls & Update dependencies 2024-06-10 23:39:52 +02:00
4b002c7b24
Fix SecurityException when importing from exposed volume 2024-06-07 16:07:20 +02:00
7c72c4e829
Update dependencies & Fix build 2024-06-06 21:08:11 +02:00
bd60e62635
Allow importing from ClipData 2024-06-03 16:11:27 +02:00
CyanWolf
d1e042c347
Update Spanish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-05-31 19:26:05 +02:00
sjceel
0805ebda35
Add Chinese-Simplified translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-05-31 19:11:53 +02:00
intergalacticmonkey
36e6ad99b3
Fix typo in Turkish strings.xml
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-05-23 13:49:58 +02:00
solokot
967d4551c5
Update Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-12 16:51:55 +01:00
Ali Beyaz
b747d2822a
Add Turkish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-12 16:47:30 +01:00
CyanWolf
e5652666d8
Update Spanish
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-11 18:12:17 +01:00
Muhmmad14333653
cda0e90b96
Update Arabic translations
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2024-02-11 18:08:54 +01:00
6f43bc7417
Avoid being killed by SELinux when retrieving volume path 2024-02-11 17:55:24 +01:00
c26ab661c2
Logcat activity 2024-01-30 18:29:49 +01:00
1c15f9fac8
Allow choosing export method 2024-01-28 15:44:53 +01:00
b4635dc2e0
Directory loading indicator 2024-01-13 23:19:22 +01:00
f4e47c1827
Allow directory creation on exposed volumes 2024-01-13 21:41:58 +01:00
5474d6eea5
Add .opus & Update build config 2024-01-13 21:25:31 +01:00
719faa31ee
Fix README 2023-10-15 17:09:48 +02:00
a41cde1c53
DroidFS v2.1.3 2023-09-28 19:36:55 +02:00
b503f134d5
Fix Intent.getParcelableExtra() crash on Android 13 2023-09-24 19:04:49 +02:00
3ba774fda3
Add Version.toString() 2023-09-19 13:47:59 +02:00
b2154d319e
Repair corrupted database due to v2.1.1 2023-09-19 13:39:35 +02:00
571a79cc1d
Really fix database upgrade 2023-09-19 11:41:01 +02:00
891a581329
Update dependencies 2023-09-17 20:10:15 +02:00
f1a9c1383c
Fix database upgrade 2023-09-17 19:11:52 +02:00
ac71ad887d
Fix README 2023-09-10 21:39:28 +02:00
e1fe329f49
Add v2.1.0 changelog 2023-09-10 21:11:04 +02:00
dfff597ae5
DroidFS v2.1.0 2023-09-10 21:01:39 +02:00
bd429648b3
Update documentation 2023-09-10 21:01:04 +02:00
71ff37b170
Fixes 2023-09-10 19:17:51 +02:00
4afe56b13c
Migrate to AndroidX Media3 2023-09-10 19:12:17 +02:00
217334a959
Fix es & de translations 2023-09-09 16:15:03 +02:00
CyanWolf
2666313676
Update Spanish translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-09-08 21:32:46 +02:00
solokot
04e154a6d9
Updated Russian translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-09-08 21:30:28 +02:00
Torsten Pfützenreuter
d3760e2194
Added German translation
Signed-off-by: Hardcore Sushi <hardcore.sushi@disroot.org>
2023-09-08 21:27:38 +02:00
d6c777875e
Fix VolumeProvider createDocument path 2023-09-08 21:22:20 +02:00
8a18270b33
Update dependencies 2023-09-08 21:13:24 +02:00
79db84f81d
Volume provider 2023-09-06 19:27:41 +02:00
6d04349b2e
Prevent volume renaming when open 2023-09-06 19:27:04 +02:00
de0194a722
Always open volume after creation 2023-08-20 17:08:10 +02:00
3127a15d9e
Fix ANR on recursive mapping 2023-08-20 16:42:40 +02:00
a08da2eacb
MemoryFileProvider 2023-08-20 14:56:46 +02:00
1727170cb6
Limit the number of thumbnails loaded concurrently 2023-08-15 18:33:29 +02:00
8776d2ee28
Add Support section in README 2023-08-15 18:06:39 +02:00
5642e28b44
Fix TODO.md 2023-05-12 20:39:58 +02:00
1b7e5904be
New screenshots 2023-05-12 20:26:45 +02:00
cb3fc3c70e
Re-ask only on wrong password 2023-05-11 21:58:55 +02:00
393c458495
Offload file discovery for copy in coroutine 2023-05-11 21:24:29 +02:00
cdf98a7190
Handle cryfs inaccessible base dir 2023-05-11 00:02:05 +02:00
2ae41f0f79
Improve file oprations coroutines 2023-05-10 23:41:29 +02:00
Muhmmad14333653
f85f9d1c44
Update arabic translation 2023-05-08 21:36:22 +02:00
9fc981fee8
Fix rotation when rebinding camera use cases 2023-05-08 21:32:04 +02:00
ad19b9e645
Update dependencies 2023-05-08 20:58:54 +02:00
87ffbc3cc1
Fix unsafe features doc link 2023-05-06 23:57:23 +02:00
b3a25e03e7
Improve video recording: fix freezes & ExoPlayer errors 2023-05-06 23:40:37 +02:00
4c412be7dc
Best error messages when opening volumes 2023-05-03 14:14:40 +02:00
f4f3239bb1
Fix volume copying 2023-05-02 14:24:59 +02:00
481558bd56
Add ecryptfs & shufflecake in TODO & Update README 2023-04-29 20:21:46 +02:00
155 changed files with 6063 additions and 2286 deletions

3
.gitmodules vendored
View File

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

View File

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

20
DONATE.txt Normal file
View File

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

View File

@ -2,14 +2,19 @@
An alternative way to use encrypted virtual filesystems on Android that uses its own internal file explorer instead of mounting volumes. An alternative way to use encrypted virtual filesystems on Android that uses its own internal file explorer instead of mounting volumes.
It currently supports [gocryptfs](https://github.com/rfjakob/gocryptfs) and [CryFS](https://github.com/cryfs/cryfs). It currently supports [gocryptfs](https://github.com/rfjakob/gocryptfs) and [CryFS](https://github.com/cryfs/cryfs).
For mortals: An encrypted file manager for Android. For mortals: Encrypted storage compatible with already existing softwares.
<p align="center"> <p align="center">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" height="500"> <img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" height="500">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" height="500"> <img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" height="500">
<img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" height="500"> <img src="https://forge.chapril.org/hardcoresushi/DroidFS/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" height="500">
</p> </p>
# Support
The creator of DroidFS works as a freelance developer and privacy consultant. I am currently looking for new clients! If you are interested, take a look at the [website](https://arkensys.dedyn.io). Alternatively, you can directly support DroidFS by making a [donation](https://forge.chapril.org/hardcoresushi/DroidFS/src/branch/master/DONATE.txt).
Thank you so much ❤️.
# Disclaimer # Disclaimer
DroidFS is provided "as is", without any warranty of any kind. DroidFS is provided "as is", without any warranty of any kind.
It shouldn't be considered as an absolute safe way to store files. It shouldn't be considered as an absolute safe way to store files.
@ -23,32 +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 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">
@ -76,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.

12
TODO.md
View File

@ -1,6 +1,6 @@
# TODO # TODO
Here is a list of features that it would be nice to have in DroidFS. Here's a list of features that it would be nice to have in DroidFS. As this is a FLOSS project, there are no special requirements on *when* or even *if* these features will be implemented, but contributions are greatly appreciated.
## Security ## Security
- [hardened_malloc](https://github.com/GrapheneOS/hardened_malloc) compatibility ([#181](https://github.com/hardcore-sushi/DroidFS/issues/181)) - [hardened_malloc](https://github.com/GrapheneOS/hardened_malloc) compatibility ([#181](https://github.com/hardcore-sushi/DroidFS/issues/181))
@ -8,13 +8,17 @@ Here is a list of features that it would be nice to have in DroidFS.
## UX ## UX
- File associations editor - File associations editor
- Modifiable CryFS scrypt parameters - Discovery before exporting
- 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
## Encryption software support
- [Shufflecake](https://shufflecake.net): plausible deniability for multiple hidden filesystems on Linux (would be absolutely awesome to have but quite difficult)
- [fscrypt](https://www.kernel.org/doc/html/latest/filesystems/fscrypt.html): filesystem encryption at the kernel level
## Health ## 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

View File

@ -5,6 +5,9 @@ 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.c)
target_link_libraries(memfile log)
if (GOCRYPTFS) if (GOCRYPTFS)
add_library(gocryptfs SHARED IMPORTED) add_library(gocryptfs SHARED IMPORTED)
set_target_properties( set_target_properties(
@ -72,4 +75,5 @@ target_link_libraries(
avformat avformat
avcodec avcodec
avutil avutil
log
) )

View File

@ -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 31 versionCode 37
versionName "2.0.1" 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.8.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"
} }

View File

@ -1,2 +1 @@
ffmpeg
build build

View File

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

@ -0,0 +1 @@
Subproject commit af25a4bfd2503caf3ee485b27b99b620302f5718

@ -1 +1 @@
Subproject commit f40c2bbdcde77d8128da181edb72bf0e7a2168b5 Subproject commit cd0af7088066f870f12eceed9836bde897f1d164

@ -1 +1 @@
Subproject commit 79f9a10e35847e46f8563941345355f15f2dba7c Subproject commit b221d4cf3c70edc711169a769a476802d2577b2b

View File

@ -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
@ -28,4 +8,11 @@
-keepclassmembers class sushi.hardcore.droidfs.video_recording.FFmpegMuxer { -keepclassmembers class sushi.hardcore.droidfs.video_recording.FFmpegMuxer {
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;
}

View File

@ -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,21 +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.RestrictedFileProvider" android:name=".content_providers.TemporaryFileProvider"
android:authorities="${applicationId}.temporary_provider" android:authorities="${applicationId}.temporary_provider"
android:exported="true"/>
<provider
android:authorities="${applicationId}.volume_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>

View File

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

View File

@ -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);
}
} }
/** /**

View File

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

View File

@ -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;
@ -1254,46 +1265,13 @@ public class SucklessEncoderImpl implements Encoder {
mVideoTimestampConverter.convertToUptimeUs(bufferInfo.presentationTimeUs); mVideoTimestampConverter.convertToUptimeUs(bufferInfo.presentationTimeUs);
} }
// MediaCodec may send out of order buffer
if (bufferInfo.presentationTimeUs <= mLastPresentationTimeUs) {
Logger.d(mTag, "Drop buffer by out of order buffer from MediaCodec.");
return false;
}
mLastPresentationTimeUs = bufferInfo.presentationTimeUs; mLastPresentationTimeUs = bufferInfo.presentationTimeUs;
// Ignore buffers are not in start/stop range. One situation is to ignore outdated
// frames when using the Surface of MediaCodec#createPersistentInputSurface. After
// the persistent Surface stops, it will keep a small number of old frames in its
// buffer, and send those old frames in the next startup.
if (!mStartStopTimeRangeUs.contains(bufferInfo.presentationTimeUs)) {
Logger.d(mTag, "Drop buffer by not in start-stop range.");
// If data hasn't reached the expected stop timestamp, set the stop timestamp.
if (mPendingCodecStop
&& bufferInfo.presentationTimeUs >= mStartStopTimeRangeUs.getUpper()) {
if (mStopTimeoutFuture != null) {
mStopTimeoutFuture.cancel(true);
}
mLastDataStopTimestamp = bufferInfo.presentationTimeUs;
signalCodecStop();
mPendingCodecStop = false;
}
return false;
}
if (updatePauseRangeStateAndCheckIfBufferPaused(bufferInfo)) { if (updatePauseRangeStateAndCheckIfBufferPaused(bufferInfo)) {
Logger.d(mTag, "Drop buffer by pause."); Logger.d(mTag, "Drop buffer by pause.");
return false; return false;
} }
// We should check if the adjusted time is valid. see b/189114207.
if (getAdjustedTimeUs(bufferInfo) <= mLastSentAdjustedTimeUs) {
Logger.d(mTag, "Drop buffer by adjusted time is less than the last sent time.");
if (mIsVideoEncoder && isKeyFrame(bufferInfo)) {
mIsKeyFrameRequired = true;
}
return false;
}
if (!mHasFirstData && !mIsKeyFrameRequired && mIsVideoEncoder) { if (!mHasFirstData && !mIsKeyFrameRequired && mIsVideoEncoder) {
mIsKeyFrameRequired = true; mIsKeyFrameRequired = true;
} }

View File

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

View File

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

View File

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

View File

@ -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);
}
} }
/** /**

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume import sushi.hardcore.droidfs.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)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,12 +16,15 @@ 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
import sushi.hardcore.droidfs.file_operations.FileOperationService import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.util.IntentUtils import sushi.hardcore.droidfs.util.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
@ -46,9 +45,9 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
finish() finish()
} }
} }
private var changePasswordPosition: Int? = null private var selectedVolumePosition: Int? = null
private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { private var changePassword = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
changePasswordPosition?.let { unselect(it) } selectedVolumePosition?.let { unselect(it) }
} }
private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> private val pickDirectory = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) if (uri != null)
@ -128,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)
} }
} }
@ -147,6 +140,7 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
recreate() recreate()
} else { } else {
volumeAdapter.refresh() volumeAdapter.refresh()
invalidateOptionsMenu()
if (volumeAdapter.volumes.isNotEmpty()) { if (volumeAdapter.volumes.isNotEmpty()) {
binding.textNoVolumes.visibility = View.GONE binding.textNoVolumes.visibility = View.GONE
} }
@ -185,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) {
@ -286,15 +278,15 @@ 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
} }
R.id.change_password -> { R.id.change_password -> {
changePasswordPosition = volumeAdapter.selectedItems.elementAt(0) selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
changePassword.launch(Intent(this, ChangePasswordActivity::class.java).apply { changePassword.launch(Intent(this, ChangePasswordActivity::class.java).apply {
putExtra("volume", volumeAdapter.volumes[changePasswordPosition!!]) putExtra("volume", volumeAdapter.volumes[selectedVolumePosition!!])
}) })
true true
} }
@ -304,26 +296,33 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
true true
} }
R.id.copy -> { R.id.copy -> {
val position = volumeAdapter.selectedItems.elementAt(0) selectedVolumePosition = volumeAdapter.selectedItems.elementAt(0)
val volume = volumeAdapter.volumes[position] val volume = volumeAdapter.volumes[selectedVolumePosition!!]
when { if (volume.isHidden) {
volume.isHidden -> { (application as VolumeManagerApp).isStartingExternalApp = true
PathUtils.safePickDirectory(pickDirectory, this, theme) PathUtils.safePickDirectory(pickDirectory, this, theme)
} } else {
File(filesDir, volume.shortName).exists() -> { val hiddenVolumeFile = File(VolumeData.getHiddenVolumeFullPath(filesDir.path, volume.shortName))
if (hiddenVolumeFile.exists()) {
CustomAlertDialogBuilder(this, theme) CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(R.string.hidden_volume_already_exists) .setMessage(R.string.hidden_volume_already_exists)
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} } else {
else -> { unselect(selectedVolumePosition!!)
unselect(position)
copyVolume( copyVolume(
DocumentFile.fromFile(File(volume.name)), DocumentFile.fromFile(File(volume.name)),
DocumentFile.fromFile(filesDir), DocumentFile.fromFile(hiddenVolumeFile.parentFile!!),
) { ) {
VolumeData(volume.shortName, true, volume.type, volume.encryptedHash, volume.iv) VolumeData(
VolumeData.newUuid(),
volume.shortName,
true,
volume.type,
volume.encryptedHash,
volume.iv
)
} }
} }
} }
@ -345,7 +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 {
@ -373,15 +376,14 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
) )
} }
} }
menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable menu.findItem(R.id.rename).isVisible = onlyOneAndWriteable && !volumeManager.isOpen(volumeAdapter.volumes[volumeAdapter.selectedItems.first()])
supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode) supportActionBar?.setDisplayHomeAsUpEnabled(isSelecting || explorerRouter.pickMode || explorerRouter.dropMode)
return true return true
} }
private fun onDirectoryPicked(uri: Uri) { private fun onDirectoryPicked(uri: Uri) {
val position = volumeAdapter.selectedItems.elementAt(0) val volume = volumeAdapter.volumes[selectedVolumePosition!!]
val volume = volumeAdapter.volumes[position] unselect(selectedVolumePosition!!)
unselect(position)
val dstDocumentFile = DocumentFile.fromTreeUri(this, uri) val dstDocumentFile = DocumentFile.fromTreeUri(this, uri)
if (dstDocumentFile == null) { if (dstDocumentFile == null) {
CustomAlertDialogBuilder(this, theme) CustomAlertDialogBuilder(this, theme)
@ -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,
@ -409,14 +412,18 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
} }
} }
/**
* Copy a volume.
*
* @param srcDocumentFile [DocumentFile] of the volume to copy
* @param dstDocumentFile [DocumentFile] of the destination PARENT FOLDER
* @param getResultVolume A function that returns the [VolumeData] corresponding to the destination volume. Takes the [DocumentFile] of the newly created volume (not the parent folder).
*/
private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> VolumeData?) { private fun copyVolume(srcDocumentFile: DocumentFile, dstDocumentFile: DocumentFile, getResultVolume: (DocumentFile) -> VolumeData?) {
lifecycleScope.launch { lifecycleScope.launch {
val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile) val result = fileOperationService.copyVolume(srcDocumentFile, dstDocumentFile)
when { when (result.taskResult.state) {
result.taskResult.cancelled -> { TaskResult.State.SUCCESS -> {
result.dstRootDirectory?.delete()
}
result.taskResult.failedItem == null -> {
result.dstRootDirectory?.let { result.dstRootDirectory?.let {
getResultVolume(it)?.let { volume -> getResultVolume(it)?.let { volume ->
volumeDatabase.saveVolume(volume) volumeDatabase.saveVolume(volume)
@ -429,13 +436,15 @@ class MainActivity : BaseActivity(), VolumeAdapter.Listener {
} }
} }
} }
else -> { TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this@MainActivity, theme) CustomAlertDialogBuilder(this@MainActivity, theme)
.setTitle(R.string.error) .setTitle(R.string.error)
.setMessage(getString(R.string.copy_failed, result.taskResult.failedItem.name)) .setMessage(getString(R.string.copy_failed, result.taskResult.failedItem!!.name))
.setPositiveButton(R.string.ok, null) .setPositiveButton(R.string.ok, null)
.show() .show()
} }
TaskResult.State.ERROR -> result.taskResult.showErrorAlertDialog(this@MainActivity, theme)
TaskResult.State.CANCELLED -> {}
} }
} }
} }
@ -458,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()) {

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package sushi.hardcore.droidfs 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
}
} }
} }
} }

View File

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

View File

@ -2,12 +2,25 @@ package sushi.hardcore.droidfs
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import sushi.hardcore.droidfs.filesystems.CryfsVolume
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
import sushi.hardcore.droidfs.filesystems.GocryptfsVolume
import sushi.hardcore.droidfs.util.PathUtils import sushi.hardcore.droidfs.util.PathUtils
import java.io.File import java.io.File
import java.io.FileInputStream
import java.util.UUID
class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte, var encryptedHash: ByteArray? = null, var iv: ByteArray? = null): Parcelable { class VolumeData(
val uuid: String,
val name: String,
val isHidden: Boolean = false,
val type: Byte,
var encryptedHash: ByteArray? = null,
var iv: ByteArray? = null
) : Parcelable {
constructor(parcel: Parcel) : this( constructor(parcel: Parcel) : this(
parcel.readString()!!,
parcel.readString()!!, parcel.readString()!!,
parcel.readByte() != 0.toByte(), parcel.readByte() != 0.toByte(),
parcel.readByte(), parcel.readByte(),
@ -19,11 +32,28 @@ 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) fun canRead(filesDir: String): Boolean {
else val volumePath = getFullPath(filesDir)
name if (!File(volumePath).canRead()) {
return false
}
val configFile = when (type) {
EncryptedVolume.GOCRYPTFS_VOLUME_TYPE -> PathUtils.pathJoin(volumePath, GocryptfsVolume.CONFIG_FILE_NAME)
EncryptedVolume.CRYFS_VOLUME_TYPE -> PathUtils.pathJoin(volumePath, CryfsVolume.CONFIG_FILE_NAME)
else -> return false
}
var success = true
try {
with (FileInputStream(configFile)) {
read()
close()
}
} catch (e: Exception) {
success = false
}
return success
} }
fun canWrite(filesDir: String): Boolean { fun canWrite(filesDir: String): Boolean {
@ -36,6 +66,7 @@ class VolumeData(val name: String, val isHidden: Boolean = false, val type: Byte
override fun writeToParcel(dest: Parcel, flags: Int) { 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)
@ -48,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 {
@ -64,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
}
} }
} }

View File

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

View File

@ -1,15 +1,30 @@
package sushi.hardcore.droidfs package sushi.hardcore.droidfs
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import sushi.hardcore.droidfs.content_providers.VolumeProvider
import sushi.hardcore.droidfs.filesystems.EncryptedVolume 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>()
private val scopes = HashMap<Int, CoroutineScope>()
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++
} }
@ -25,18 +40,36 @@ 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 {
return scopes[volumeId] ?: CoroutineScope(SupervisorJob() + Dispatchers.IO).also { scopes[volumeId] = it }
}
fun closeVolume(id: Int) { fun closeVolume(id: Int) {
volumes.remove(id)?.let { volume -> volumes.remove(id)?.let { volume ->
volume.close() scopes[id]?.cancel()
volumesData.filter { it.value == id }.forEach { volume.closeVolume()
volumesData.remove(it.key) volumesData.filter { it.value == id }.forEach { entry ->
volumesData.remove(entry.key)
observers.forEach { it.onVolumeStateChanged(entry.key) }
} }
VolumeProvider.notifyRootsChanged(context)
} }
} }
fun closeAll() { fun closeAll() {
volumes.forEach { it.value.close() } volumes.forEach {
scopes[it.key]?.cancel()
it.value.closeVolume()
}
volumes.clear() volumes.clear()
volumesData.clear() volumesData.clear()
observers.forEach { it.onAllVolumesClosed() }
VolumeProvider.notifyRootsChanged(context)
} }
} }

View File

@ -1,39 +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.RestrictedFileProvider import sushi.hardcore.droidfs.util.AndroidUtils
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) {
@ -42,10 +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()
} }
RestrictedFileProvider.wipeAll(applicationContext) if (!usfBackground || !isExporting) {
TemporaryFileProvider.instance.wipe()
}
} }
} }
} }

View File

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

View File

@ -1,17 +0,0 @@
package sushi.hardcore.droidfs
import android.app.Service
import android.content.Intent
import android.os.IBinder
class WiperService : Service() {
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onTaskRemoved(rootIntent: Intent) {
super.onTaskRemoved(rootIntent)
(application as VolumeManagerApp).volumeManager.closeAll()
stopSelf()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
@ -33,6 +45,17 @@ class CreateVolumeFragment: Fragment() {
private const val KEY_REMEMBER_VOLUME = "remember" private const val KEY_REMEMBER_VOLUME = "remember"
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,
@ -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,
@ -190,14 +225,14 @@ class CreateVolumeFragment: Fragment() {
encryptedVolume, encryptedVolume,
), EncryptedVolume.GOCRYPTFS_VOLUME_TYPE) ), EncryptedVolume.GOCRYPTFS_VOLUME_TYPE)
} else { } else {
generateResult(CryfsVolume.create( encryptedVolume.value = CryfsVolume.create(
volumePath, volumePath,
CryfsVolume.getLocalStateDir(activity.filesDir.path), CryfsVolume.getLocalStateDir(activity.filesDir.path),
password, password,
returnedHash, returnedHash,
resources.getStringArray(R.array.cryfs_encryption_ciphers)[binding.spinnerCipher.selectedItemPosition], resources.getStringArray(R.array.cryfs_encryption_ciphers)[binding.spinnerCipher.selectedItemPosition],
encryptedVolume, )
), EncryptedVolume.CRYFS_VOLUME_TYPE) generateResult(encryptedVolume.value != null, EncryptedVolume.CRYFS_VOLUME_TYPE)
} }
Arrays.fill(password, 0) Arrays.fill(password, 0)
return result return result
@ -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)
} }
@ -256,7 +291,7 @@ class CreateVolumeFragment: Fragment() {
private fun onVolumeCreated(id: Int?, volumeShortName: String) { private fun onVolumeCreated(id: Int?, volumeShortName: String) {
(activity as AddVolumeActivity).apply { (activity as AddVolumeActivity).apply {
if (rememberVolume || id == null) { if (id == null) {
finish() finish()
} else { } else {
startExplorer(id, volumeShortName) startExplorer(id, volumeShortName)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,147 @@
package sushi.hardcore.droidfs.content_providers
import android.content.ContentProvider
import android.content.ContentValues
import android.content.Intent
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.provider.OpenableColumns
import android.util.Log
import android.webkit.MimeTypeMap
import 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.util.UUID
class TemporaryFileProvider : ContentProvider() {
private inner class ProvidedFile(
val file: EncryptedFileProvider.ExportedFile,
val size: Long,
val volumeId: Int
)
companion object {
private const val TAG = "TemporaryFileProvider"
private const val AUTHORITY = BuildConfig.APPLICATION_ID + ".temporary_provider"
private val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")
lateinit var instance: TemporaryFileProvider
private set
}
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 {
addRow(arrayOf(File(file.file.path).name, file.size))
}
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
throw UnsupportedOperationException("Operation not supported")
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<String>?
): Int {
throw UnsupportedOperationException("Operation not supported")
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
return if (files.remove(uri)?.file?.also { it.free() } == null) 0 else 1
}
override fun getType(uri: Uri): String = files[uri]?.file?.path?.let {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(File(it).extension)
} ?: "application/octet-stream"
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
files[uri]?.let { file ->
val encryptedVolume = volumeManager.getVolume(file.volumeId) ?: run {
Log.e(TAG, "Volume closed for $uri")
return null
}
val result = encryptedFileProvider.openFile(
file.file,
mode,
encryptedVolume,
volumeManager.getCoroutineScope(file.volumeId),
false,
usfSafWrite,
)
when (result.second) {
EncryptedFileProvider.Error.SUCCESS -> return result.first!!
EncryptedFileProvider.Error.WRITE_ACCESS_DENIED -> Log.e(
TAG,
"Unauthorized write access requested from $callingPackage to $uri"
)
else -> result.second.log()
}
}
return null
}
// this must not be cancelled
fun wipe() = GlobalScope.launch(Dispatchers.IO) {
context!!.revokeUriPermission(BASE_URI, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
synchronized(this@TemporaryFileProvider) {
for (i in files.values) {
i.file.free()
}
files.clear()
}
}
}

View File

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

View File

@ -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
@ -22,17 +19,37 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager 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.* import kotlinx.coroutines.Dispatchers
import sushi.hardcore.droidfs.* import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.Constants
import sushi.hardcore.droidfs.EncryptedFileProvider
import sushi.hardcore.droidfs.FileShare
import sushi.hardcore.droidfs.FileTypes
import sushi.hardcore.droidfs.LoadingTask
import sushi.hardcore.droidfs.R
import sushi.hardcore.droidfs.VolumeManagerApp
import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter import sushi.hardcore.droidfs.adapters.ExplorerElementAdapter
import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter import sushi.hardcore.droidfs.adapters.OpenAsDialogAdapter
import sushi.hardcore.droidfs.content_providers.ExternalProvider import sushi.hardcore.droidfs.content_providers.TemporaryFileProvider
import sushi.hardcore.droidfs.file_operations.FileOperationService import sushi.hardcore.droidfs.file_operations.FileOperationService
import sushi.hardcore.droidfs.file_operations.OperationFile import sushi.hardcore.droidfs.file_operations.OperationFile
import sushi.hardcore.droidfs.file_viewers.* import sushi.hardcore.droidfs.file_operations.TaskResult
import sushi.hardcore.droidfs.file_viewers.AudioPlayer
import sushi.hardcore.droidfs.file_viewers.ImageViewer
import sushi.hardcore.droidfs.file_viewers.PdfViewer
import sushi.hardcore.droidfs.file_viewers.TextEditor
import sushi.hardcore.droidfs.file_viewers.VideoPlayer
import sushi.hardcore.droidfs.filesystems.EncryptedVolume import sushi.hardcore.droidfs.filesystems.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
@ -42,7 +59,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
private var foldersFirst = true private var foldersFirst = true
private var mapFolders = true private var mapFolders = true
private var currentSortOrderIndex = 0 private var currentSortOrderIndex = 0
private var volumeId = -1 protected var volumeId = -1
protected lateinit var encryptedVolume: EncryptedVolume protected lateinit var encryptedVolume: EncryptedVolume
private lateinit var volumeName: String private lateinit var volumeName: String
private lateinit var explorerViewModel: ExplorerViewModel private lateinit var explorerViewModel: ExplorerViewModel
@ -52,7 +69,8 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
explorerViewModel.currentDirectoryPath = value explorerViewModel.currentDirectoryPath = value
} }
protected lateinit var fileOperationService: FileOperationService protected lateinit var fileOperationService: FileOperationService
protected val taskScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) protected val activityScope = MainScope()
private var directoryLoadingTask: Job? = null
protected lateinit var explorerElements: MutableList<ExplorerElement> protected lateinit var explorerElements: MutableList<ExplorerElement>
protected lateinit var explorerAdapter: ExplorerElementAdapter protected lateinit var explorerAdapter: ExplorerElementAdapter
protected lateinit var app: VolumeManagerApp protected lateinit var app: VolumeManagerApp
@ -63,11 +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 { FileShare(this) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -75,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)
@ -84,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)
@ -108,7 +129,6 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
) )
explorerViewModel = ViewModelProvider(this).get(ExplorerViewModel::class.java) explorerViewModel = ViewModelProvider(this).get(ExplorerViewModel::class.java)
currentDirectoryPath = explorerViewModel.currentDirectoryPath currentDirectoryPath = explorerViewModel.currentDirectoryPath
setCurrentPath(currentDirectoryPath)
linearLayoutManager = LinearLayoutManager(this@BaseExplorerActivity) linearLayoutManager = LinearLayoutManager(this@BaseExplorerActivity)
recycler_view_explorer.adapter = explorerAdapter recycler_view_explorer.adapter = explorerAdapter
isUsingListLayout = sharedPrefs.getBoolean("useListLayout", true) isUsingListLayout = sharedPrefs.getBoolean("useListLayout", true)
@ -165,36 +185,58 @@ 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()
binder.setEncryptedVolume(encryptedVolume)
}
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)
} }
private fun openWithExternalApp(fullPath: String) { protected fun onExportFailed(errorResId: Int) {
app.isStartingExternalApp = true CustomAlertDialogBuilder(this, theme)
ExternalProvider.open(this, theme, encryptedVolume, fullPath) .setTitle(R.string.error)
.setMessage(getString(R.string.tmp_export_failed, getString(errorResId)))
.setPositiveButton(R.string.ok, null)
.show()
} }
private fun showOpenAsDialog(path: String) { private fun openWithExternalApp(path: String, size: Long) {
app.isExporting = true
val exportedFile = TemporaryFileProvider.instance.encryptedFileProvider.createFile(path, size)
if (exportedFile == null) {
onExportFailed(R.string.export_failed_create)
return
}
val msg = when (exportedFile) {
is EncryptedFileProvider.ExportedMemFile -> R.string.export_mem
is EncryptedFileProvider.ExportedDiskFile -> R.string.export_disk
else -> R.string.loading_msg_export
}
object : LoadingTask<Pair<Intent?, Int?>>(this, theme, msg) {
override suspend fun doTask(): Pair<Intent?, Int?> {
return fileShare.openWith(exportedFile, size, volumeId)
}
}.startTask(lifecycleScope) { (intent, error) ->
if (intent == null) {
onExportFailed(error!!)
} else {
app.isStartingExternalApp = true
startActivity(intent)
}
app.isExporting = false
}
}
private fun showOpenAsDialog(explorerElement: ExplorerElement) {
val path = explorerElement.fullPath
val adapter = OpenAsDialogAdapter(this, usf_open) val adapter = OpenAsDialogAdapter(this, usf_open)
CustomAlertDialogBuilder(this, theme) CustomAlertDialogBuilder(this, theme)
.setSingleChoiceItems(adapter, -1) { dialog, which -> .setSingleChoiceItems(adapter, -1) { dialog, which ->
@ -205,7 +247,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
"pdf" -> startFileViewer(PdfViewer::class.java, path) "pdf" -> startFileViewer(PdfViewer::class.java, path)
"text" -> startFileViewer(TextEditor::class.java, path) "text" -> startFileViewer(TextEditor::class.java, path)
"external" -> if (usf_open) { "external" -> if (usf_open) {
openWithExternalApp(path) openWithExternalApp(path, explorerElement.stat.size)
} }
} }
dialog.dismiss() dialog.dismiss()
@ -215,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)
} }
@ -252,7 +315,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
FileTypes.isAudio(fullPath) -> { FileTypes.isAudio(fullPath) -> {
startFileViewer(AudioPlayer::class.java, fullPath) startFileViewer(AudioPlayer::class.java, fullPath)
} }
else -> showOpenAsDialog(fullPath) else -> showOpenAsDialog(explorerElements[position])
} }
} }
invalidateOptionsMenu() invalidateOptionsMenu()
@ -267,19 +330,16 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
invalidateOptionsMenu() invalidateOptionsMenu()
} }
private fun displayExplorerElements(totalSize: Long) { private fun displayExplorerElements() {
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize)) ExplorerElement.sortBy(sortOrderValues[currentSortOrderIndex], foldersFirst, explorerElements)
synchronized(this) {
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)
@ -303,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
@ -319,23 +380,25 @@ 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(totalSize)
onDisplayed?.invoke()
} }
directoryLoadingTask!!.join()
displayExplorerElements()
totalSizeText.text = getString(R.string.total_size, PathUtils.formatSize(totalSize))
onDisplayed?.invoke()
} else { } else {
displayExplorerElements(explorerElements.filter { !it.isParentFolder }.sumOf { it.stat.size }) displayExplorerElements()
totalSizeText.text = getString(
R.string.total_size,
PathUtils.formatSize(explorerElements.filter { !it.isParentFolder }.sumOf { it.stat.size })
)
onDisplayed?.invoke() onDisplayed?.invoke()
} }
} }
@ -375,10 +438,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
}.show() }.show()
} }
protected fun checkPathOverwrite(items: ArrayList<OperationFile>, dstDirectoryPath: String, callback: (ArrayList<OperationFile>?) -> Unit) { protected fun checkPathOverwrite(items: List<OperationFile>, dstDirectoryPath: String, callback: (List<OperationFile>?) -> Unit) {
val srcDirectoryPath = items[0].parentPath val srcDirectoryPath = items[0].parentPath
var ready = true var ready = true
for (i in 0 until items.size) { for (i in items.indices) {
val testDstPath: String val testDstPath: String
if (items[i].dstPath == null){ if (items[i].dstPath == null){
testDstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].srcPath)) testDstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].srcPath))
@ -412,7 +475,7 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
with(EditTextDialog(this, R.string.enter_new_name) { with(EditTextDialog(this, R.string.enter_new_name) {
items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].parentPath), it) items[i].dstPath = PathUtils.pathJoin(dstDirectoryPath, PathUtils.getRelativePath(srcDirectoryPath, items[i].parentPath), it)
if (items[i].isDirectory) { if (items[i].isDirectory) {
for (j in 0 until items.size){ for (j in items.indices) {
if (PathUtils.isChildOf(items[j].srcPath, items[i].srcPath)) { if (PathUtils.isChildOf(items[j].srcPath, items[i].srcPath)) {
items[j].dstPath = PathUtils.pathJoin(items[i].dstPath!!, PathUtils.getRelativePath(items[i].srcPath, items[j].srcPath)) items[j].dstPath = PathUtils.pathJoin(items[i].dstPath!!, PathUtils.getRelativePath(items[i].srcPath, items[j].srcPath))
} }
@ -439,7 +502,33 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
} }
} }
protected fun importFilesFromUris(uris: List<Uri>, callback: (String?) -> Unit) { protected fun onTaskResult(
result: TaskResult<out String?>,
failedErrorMessage: Int,
successMessage: Int = -1,
onSuccess: (() -> Unit)? = null,
) {
when (result.state) {
TaskResult.State.SUCCESS -> {
if (onSuccess == null) {
Toast.makeText(this, successMessage, Toast.LENGTH_SHORT).show()
} else {
onSuccess()
}
}
TaskResult.State.FAILED -> {
CustomAlertDialogBuilder(this, theme)
.setTitle(R.string.error)
.setMessage(getString(failedErrorMessage, result.failedItem))
.setPositiveButton(R.string.ok, null)
.show()
}
TaskResult.State.ERROR -> result.showErrorAlertDialog(this, theme)
TaskResult.State.CANCELLED -> {}
}
}
protected fun importFilesFromUris(uris: List<Uri>, callback: () -> Unit) {
val items = ArrayList<OperationFile>() val items = ArrayList<OperationFile>()
for (uri in uris) { for (uri in uris) {
val fileName = PathUtils.getFilenameFromURI(this, uri) val fileName = PathUtils.getFilenameFromURI(this, uri)
@ -458,13 +547,10 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
if (items.size > 0) { if (items.size > 0) {
checkPathOverwrite(items, currentDirectoryPath) { checkedItems -> checkPathOverwrite(items, currentDirectoryPath) { checkedItems ->
checkedItems?.let { checkedItems?.let {
taskScope.launch { activityScope.launch {
val taskResult = fileOperationService.importFilesFromUris(checkedItems.map { it.dstPath!! }, uris) val result = fileOperationService.importFilesFromUris(volumeId, checkedItems.map { it.dstPath!! }, uris)
if (taskResult.cancelled) { onTaskResult(result, R.string.import_failed, onSuccess = callback)
setCurrentPath(currentDirectoryPath) setCurrentPath(currentDirectoryPath)
} else {
callback(taskResult.failedItem)
}
} }
} }
} }
@ -489,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
@ -504,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
@ -536,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
setCurrentPath(currentDirectoryPath) // displayExplorerElements must not be called if directoryLoadingTask is active
if (directoryLoadingTask?.isActive != true) {
displayExplorerElements()
}
val sharedPrefsEditor = sharedPrefs.edit()
sharedPrefsEditor.putString(Constants.SORT_ORDER_KEY, sortOrderValues[currentSortOrderIndex])
sharedPrefsEditor.apply()
dialog.dismiss() dialog.dismiss()
} }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
@ -554,22 +639,13 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
true true
} }
R.id.open_as -> { R.id.open_as -> {
showOpenAsDialog( showOpenAsDialog(explorerElements[explorerAdapter.selectedItems.first()])
PathUtils.pathJoin(
currentDirectoryPath,
explorerElements[explorerAdapter.selectedItems.first()].name
)
)
true true
} }
R.id.external_open -> { R.id.external_open -> {
if (usf_open){ if (usf_open){
openWithExternalApp( val explorerElement = explorerElements[explorerAdapter.selectedItems.first()]
PathUtils.pathJoin( openWithExternalApp(explorerElement.fullPath, explorerElement.stat.size)
currentDirectoryPath,
explorerElements[explorerAdapter.selectedItems.first()].name
)
)
unselectAll() unselectAll()
} }
true true
@ -589,19 +665,15 @@ open class BaseExplorerActivity : BaseActivity(), ExplorerElementAdapter.Listene
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
if (!isChangingConfigurations) { //activity won't be recreated if (!isChangingConfigurations) { //activity won't be recreated
taskScope.cancel() activityScope.cancel()
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (app.isStartingExternalApp) { if (app.isStartingExternalApp) {
ExternalProvider.removeFilesAsync(this) TemporaryFileProvider.instance.wipe()
}
if (encryptedVolume.isClosed()) {
finish()
} else {
setCurrentPath(currentDirectoryPath)
} }
setCurrentPath(currentDirectoryPath)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,55 +1,182 @@
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 kotlinx.coroutines.* import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import sushi.hardcore.droidfs.BaseActivity
import sushi.hardcore.droidfs.Constants import sushi.hardcore.droidfs.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.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 encryptedVolume: EncryptedVolume
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
fun setEncryptedVolume(volume: EncryptedVolume) { }
encryptedVolume = volume
inner class PendingTask<T>(
val title: Int,
val total: Int?,
private val getTask: (Int) -> Deferred<T>,
private val onStart: (taskId: Int, job: Deferred<T>) -> Unit,
) {
fun start(taskId: Int): Deferred<T> = getTask(taskId).also { onStart(taskId, it) }
}
companion object {
const val TAG = "FileOperationService"
const val NOTIFICATION_CHANNEL_ID = "FileOperations"
const val ACTION_CANCEL = "file_operation_cancel"
/**
* Bind to the service.
*
* Registers an [ActivityResultLauncher] in the provided activity to request notification permission. Consequently, the activity must not yet be started.
*
* The activity must stay running while calling the service's methods.
*
* If multiple activities bind simultaneously, only the latest one will be used by the service.
*/
fun bind(activity: BaseActivity, onBound: (FileOperationService) -> Unit) {
val helper = AndroidUtils.NotificationPermissionHelper(activity)
lateinit var service: FileOperationService
val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, binder: IBinder) {
onBound((binder as FileOperationService.LocalBinder).getService().also {
service = it
it.notificationPermissionHelpers.addLast(helper)
})
}
override fun onServiceDisconnected(arg0: ComponentName) {}
}
activity.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
activity.unbindService(serviceConnection)
service.notificationPermissionHelpers.removeLast()
}
})
activity.bindService(
Intent(activity, FileOperationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
} }
} }
override fun onBind(p0: Intent?): IBinder { private var isStarted = false
return binder 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
} }
private fun showNotification(message: Int, total: Int?): FileOperationNotification { override fun onBind(p0: Intent?): IBinder = binder
++lastNotificationId
if (!::notificationManager.isInitialized){ 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) {
@ -62,77 +189,205 @@ 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){ private fun getEncryptedVolume(volumeId: Int): EncryptedVolume {
tasks[notificationId]?.cancel() return volumeManger.getVolume(volumeId) ?: throw IllegalArgumentException("Invalid volumeId: $volumeId")
} }
class TaskResult<T>(val cancelled: Boolean, val failedItem: T?) /**
* Wait on a task, returning the appropriate [TaskResult].
private suspend fun <T> waitForTask(notification: FileOperationNotification, task: Deferred<T>): TaskResult<T> { *
tasks[notification.notificationId] = task * This method also performs cleanup and foreground state management so it must be always used.
return try { */
TaskResult(false, task.await()) private suspend fun <T> waitForTask(
} catch (e: CancellationException) { taskId: Int,
TaskResult(true, null) task: Deferred<T>,
} finally { onCancelled: (suspend () -> Unit)?,
cancelNotification(notification) ): TaskResult<out T> {
return coroutineScope {
withContext(serviceScope.coroutineContext) {
try {
TaskResult.completed(task.await())
} catch (e: CancellationException) {
onCancelled?.invoke()
TaskResult.cancelled()
} catch (e: Throwable) {
e.printStackTrace()
TaskResult.error(e.localizedMessage)
} finally {
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 fun copyFile(srcPath: String, dstPath: String, remoteEncryptedVolume: EncryptedVolume = encryptedVolume): Boolean { /**
* Create and run a new task until completion.
*
* Handles notification permission request, service startup and notification management.
*
* Overrides [pendingTask] without checking! (safe if user is not insanely fast)
*/
private suspend fun <T> newTask(
title: Int,
total: Int?,
getTask: (taskId: Int) -> Deferred<T>,
onCancelled: (suspend () -> Unit)?,
): TaskResult<out T> {
val startedTask = suspendCoroutine { continuation ->
val task = PendingTask(title, total, getTask) { taskId, job ->
continuation.resume(Pair(taskId, job))
}
pendingTask = task
if (askForNotificationPermission) {
with (notificationPermissionHelpers.last()) {
askAndRun { granted ->
if (granted) {
processPendingTask()
} else {
CustomAlertDialogBuilder(activity, activity.theme)
.setTitle(R.string.warning)
.setMessage(R.string.notification_denied_msg)
.setPositiveButton(R.string.settings) { _, _ ->
(application as VolumeManagerApp).isStartingExternalApp = true
activity.startActivity(
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", packageName, null)
)
)
}
.setNegativeButton(R.string.later, null)
.setOnDismissListener { processPendingTask() }
.show()
}
}
}
askForNotificationPermission = false // only ask once per service instance
return@suspendCoroutine
}
processPendingTask()
}
return waitForTask(startedTask.first, startedTask.second, onCancelled)
}
private suspend fun <T> volumeTask(
title: Int,
total: Int?,
volumeId: Int,
task: suspend (taskId: Int, encryptedVolume: EncryptedVolume) -> T
): 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(
encryptedVolume: EncryptedVolume,
srcPath: String,
dstPath: String,
srcEncryptedVolume: EncryptedVolume = encryptedVolume,
): Boolean {
var success = true var success = true
val srcFileHandle = remoteEncryptedVolume.openFileReadMode(srcPath) val srcFileHandle = srcEncryptedVolume.openFileReadMode(srcPath)
if (srcFileHandle != -1L) { if (srcFileHandle != -1L) {
val dstFileHandle = encryptedVolume.openFileWriteMode(dstPath) val dstFileHandle = encryptedVolume.openFileWriteMode(dstPath)
if (dstFileHandle != -1L) { if (dstFileHandle != -1L) {
var offset: Long = 0 var offset: Long = 0
val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE) val ioBuffer = ByteArray(Constants.IO_BUFF_SIZE)
var length: Long var length: Long
while (remoteEncryptedVolume.read(srcFileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it.toLong() } > 0) { while (srcEncryptedVolume.read(srcFileHandle, offset, ioBuffer, 0, ioBuffer.size.toLong()).also { length = it.toLong() } > 0) {
yield()
val written = encryptedVolume.write(dstFileHandle, offset, ioBuffer, 0, length).toLong() val written = encryptedVolume.write(dstFileHandle, offset, ioBuffer, 0, length).toLong()
if (written == length) { if (written == length) {
offset += written offset += written
@ -146,7 +401,7 @@ class FileOperationService : Service() {
} else { } else {
success = false success = false
} }
remoteEncryptedVolume.closeFile(srcFileHandle) srcEncryptedVolume.closeFile(srcFileHandle)
} else { } else {
success = false success = false
} }
@ -154,41 +409,36 @@ class FileOperationService : Service() {
} }
suspend fun copyElements( suspend fun copyElements(
items: ArrayList<OperationFile>, volumeId: Int,
remoteEncryptedVolume: EncryptedVolume = encryptedVolume items: List<OperationFile>,
): String? = coroutineScope { srcVolumeId: Int = volumeId,
val notification = showNotification(R.string.file_op_copy_msg, items.size) ): TaskResult<out String?> {
val task = async { val srcEncryptedVolume = getEncryptedVolume(srcVolumeId)
return volumeTask(R.string.file_op_copy_msg, items.size, volumeId) { taskId, encryptedVolume ->
var failedItem: String? = null var failedItem: String? = null
for (i in 0 until items.size) { for (i in items.indices) {
withContext(Dispatchers.IO) { yield()
if (items[i].isDirectory) { if (items[i].isDirectory) {
if (!encryptedVolume.pathExists(items[i].dstPath!!)) { if (!encryptedVolume.pathExists(items[i].dstPath!!)) {
if (!encryptedVolume.mkdir(items[i].dstPath!!)) { if (!encryptedVolume.mkdir(items[i].dstPath!!)) {
failedItem = items[i].srcPath
}
}
} else {
if (!copyFile(items[i].srcPath, items[i].dstPath!!, remoteEncryptedVolume)) {
failedItem = items[i].srcPath failedItem = items[i].srcPath
} }
} }
} else if (!copyFile(encryptedVolume, items[i].srcPath, items[i].dstPath!!, srcEncryptedVolume)) {
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
} }
} }
failedItem failedItem
} }
// treat cancellation as success
waitForTask(notification, task).failedItem
} }
suspend fun moveElements(toMove: List<OperationFile>, toClean: List<String>): String? = coroutineScope { suspend fun moveElements(volumeId: Int, toMove: List<OperationFile>, toClean: List<String>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_move_msg, toMove.size) return volumeTask(R.string.file_op_move_msg, toMove.size, volumeId) { taskId, encryptedVolume ->
val task = async(Dispatchers.IO) {
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()) {
@ -196,7 +446,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) {
@ -205,34 +455,32 @@ 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)
} }
} }
} }
failedItem failedItem
} }
// treat cancellation as success
waitForTask(notification, task).failedItem
} }
private suspend fun importFilesFromUris( private suspend fun importFilesFromUris(
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) {
withContext(Dispatchers.IO) { yield()
try { try {
if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) { if (!encryptedVolume.importFile(this@FileOperationService, uris[i], dstPaths[i])) {
failedIndex = i
}
} catch (e: FileNotFoundException) {
failedIndex = i failedIndex = i
} }
} catch (e: FileNotFoundException) {
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()
} }
@ -240,94 +488,75 @@ class FileOperationService : Service() {
return null return null
} }
suspend fun importFilesFromUris(dstPaths: List<String>, uris: List<Uri>): TaskResult<String?> = coroutineScope { suspend fun importFilesFromUris(volumeId: Int, dstPaths: List<String>, uris: List<Uri>): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_import_msg, dstPaths.size) return volumeTask(R.string.file_op_import_msg, dstPaths.size, volumeId) { taskId, encryptedVolume ->
val task = async { importFilesFromUris(encryptedVolume, dstPaths, uris, taskId)
importFilesFromUris(dstPaths, uris, notification)
} }
waitForTask(notification, task)
} }
/** /**
* 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 fun recursiveMapDirectoryForImport( private suspend fun recursiveMapDirectoryForImport(
rootSrcDir: DocumentFile, rootSrcDir: DocumentFile,
rootDstPath: String, rootDstPath: String,
dstFiles: ArrayList<String>, dstFiles: ArrayList<String>,
srcUris: ArrayList<Uri>, srcUris: ArrayList<Uri>,
dstDirs: ArrayList<String>, dstDirs: ArrayList<String>,
scope: CoroutineScope, ) {
): Boolean {
dstDirs.add(rootDstPath) dstDirs.add(rootDstPath)
for (child in rootSrcDir.listFiles()) { for (child in rootSrcDir.listFiles()) {
if (!scope.isActive) { yield()
return false
}
child.name?.let { name -> child.name?.let { name ->
val subPath = PathUtils.pathJoin(rootDstPath, name) val subPath = PathUtils.pathJoin(rootDstPath, name)
if (child.isDirectory) { if (child.isDirectory) {
if (!recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs, scope)) { recursiveMapDirectoryForImport(child, subPath, dstFiles, srcUris, dstDirs)
return false } else if (child.isFile) {
}
}
else if (child.isFile) {
srcUris.add(child.uri) srcUris.add(child.uri)
dstFiles.add(subPath) dstFiles.add(subPath)
} }
} }
} }
return true
} }
class ImportDirectoryResult(val taskResult: TaskResult<String?>, val uris: List<Uri>) class ImportDirectoryResult(val taskResult: TaskResult<out String?>, val uris: List<Uri>)
suspend fun importDirectory( suspend fun importDirectory(
volumeId: Int,
rootDstPath: String, rootDstPath: String,
rootSrcDir: DocumentFile, rootSrcDir: DocumentFile,
): ImportDirectoryResult = coroutineScope { ): ImportDirectoryResult {
val notification = showNotification(R.string.file_op_import_msg, null)
val srcUris = arrayListOf<Uri>() val srcUris = arrayListOf<Uri>()
val task = async { 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)
withContext(Dispatchers.IO) { // create destination folders so the new files can use them
if (!recursiveMapDirectoryForImport(rootSrcDir, rootDstPath, dstFiles, srcUris, dstDirs, this)) { for (dir in dstDirs) {
return@withContext // if directory creation fails, check if it was already present
} if (!encryptedVolume.mkdir(dir) && encryptedVolume.getAttr(dir)?.type != Stat.S_IFDIR) {
failedItem = dir
// create destination folders so the new files can use them break
for (dir in dstDirs) {
if (!encryptedVolume.mkdir(dir)) {
failedItem = dir
break
}
} }
} }
if (failedItem == null) { if (failedItem == null) {
failedItem = importFilesFromUris(dstFiles, srcUris, notification) failedItem = importFilesFromUris(encryptedVolume, dstFiles, srcUris, taskId)
} }
failedItem failedItem
} }, srcUris)
ImportDirectoryResult(waitForTask(notification, task), srcUris)
} }
suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): String? = coroutineScope { suspend fun wipeUris(uris: List<Uri>, rootFile: DocumentFile? = null): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_wiping_msg, uris.size) return globalTask(R.string.file_op_wiping_msg, uris.size, { taskId ->
val task = async {
var errorMsg: String? = null var errorMsg: String? = null
for (i in uris.indices) { for (i in uris.indices) {
withContext(Dispatchers.IO) { 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
} }
@ -336,12 +565,10 @@ class FileOperationService : Service() {
rootFile?.delete() rootFile?.delete()
} }
errorMsg errorMsg
} })
// treat cancellation as success
waitForTask(notification, task).failedItem
} }
private fun exportFileInto(srcPath: String, treeDocumentFile: DocumentFile): Boolean { private fun exportFileInto(encryptedVolume: EncryptedVolume, srcPath: String, treeDocumentFile: DocumentFile): Boolean {
val outputStream = treeDocumentFile.createFile("*/*", File(srcPath).name)?.uri?.let { val outputStream = treeDocumentFile.createFile("*/*", File(srcPath).name)?.uri?.let {
contentResolver.openOutputStream(it) contentResolver.openOutputStream(it)
} }
@ -352,25 +579,20 @@ class FileOperationService : Service() {
} }
} }
private fun recursiveExportDirectory( private suspend fun recursiveExportDirectory(
encryptedVolume: EncryptedVolume,
plain_directory_path: String, plain_directory_path: String,
treeDocumentFile: DocumentFile, treeDocumentFile: DocumentFile,
scope: CoroutineScope
): String? { ): String? {
treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree -> treeDocumentFile.createDirectory(File(plain_directory_path).name)?.let { childTree ->
val explorerElements = encryptedVolume.readDir(plain_directory_path) ?: return null val explorerElements = encryptedVolume.readDir(plain_directory_path) ?: return null
for (e in explorerElements) { for (e in explorerElements) {
if (!scope.isActive) { yield()
return null
}
val fullPath = PathUtils.pathJoin(plain_directory_path, e.name) val fullPath = PathUtils.pathJoin(plain_directory_path, e.name)
if (e.isDirectory) { if (e.isDirectory) {
val failedItem = recursiveExportDirectory(fullPath, childTree, scope) recursiveExportDirectory(encryptedVolume, fullPath, childTree)?.let { return it }
failedItem?.let { return it } } else if (!exportFileInto(encryptedVolume, fullPath, childTree)) {
} else { return fullPath
if (!exportFileInto(fullPath, childTree)){
return fullPath
}
} }
} }
return null return null
@ -378,84 +600,94 @@ class FileOperationService : Service() {
return treeDocumentFile.name return treeDocumentFile.name
} }
suspend fun exportFiles(uri: Uri, items: List<ExplorerElement>): TaskResult<String?> = coroutineScope { suspend fun exportFiles(volumeId: Int, items: List<ExplorerElement>, uri: Uri): TaskResult<out String?> {
val notification = showNotification(R.string.file_op_export_msg, items.size) return volumeTask(R.string.file_op_export_msg, items.size, volumeId) { taskId, encryptedVolume ->
val task = async {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
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) {
withContext(Dispatchers.IO) { yield()
failedItem = if (items[i].isDirectory) { failedItem = if (items[i].isDirectory) {
recursiveExportDirectory(items[i].fullPath, treeDocumentFile, this) recursiveExportDirectory(encryptedVolume, items[i].fullPath, treeDocumentFile)
} else {
if (exportFileInto(encryptedVolume, items[i].fullPath, treeDocumentFile)) {
null
} else { } else {
if (exportFileInto(items[i].fullPath, treeDocumentFile)) null else items[i].fullPath items[i].fullPath
} }
} }
if (failedItem == null) { if (failedItem == null) {
updateNotificationProgress(notification, i+1, items.size) updateNotificationProgress(taskId, i+1, items.size)
} else { } else {
break break
} }
} }
failedItem failedItem
} }
waitForTask(notification, task)
} }
suspend fun removeElements(items: List<ExplorerElement>): String? = coroutineScope { private suspend fun recursiveRemoveDirectory(encryptedVolume: EncryptedVolume, path: String): String? {
val notification = showNotification(R.string.file_op_delete_msg, items.size) encryptedVolume.readDir(path)?.let { elements ->
val task = async(Dispatchers.IO) { for (e in elements) {
yield()
val fullPath = PathUtils.pathJoin(path, e.name)
if (e.isDirectory) {
recursiveRemoveDirectory(encryptedVolume, fullPath)?.let { return it }
} else if (!encryptedVolume.deleteFile(fullPath)) {
return fullPath
}
}
}
return if (!encryptedVolume.rmdir(path)) {
path
} else {
null
}
}
suspend fun removeElements(volumeId: Int, items: List<ExplorerElement>): String? {
return volumeTask(R.string.file_op_delete_msg, items.size, volumeId) { taskId, encryptedVolume ->
var failedItem: String? = null var failedItem: String? = null
for ((i, element) in items.withIndex()) { for ((i, element) in items.withIndex()) {
yield()
if (element.isDirectory) { if (element.isDirectory) {
val result = encryptedVolume.recursiveRemoveDirectory(element.fullPath) recursiveRemoveDirectory(encryptedVolume, element.fullPath)?.let { failedItem = it }
result?.let { failedItem = it } } else if (!encryptedVolume.deleteFile(element.fullPath)) {
} else { failedItem = element.fullPath
if (!encryptedVolume.deleteFile(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
} }
} }
failedItem failedItem
} }.failedItem // treat cancellation as success
waitForTask(notification, task).failedItem
} }
private fun recursiveCountChildElements(rootDirectory: DocumentFile, scope: CoroutineScope): Int { private suspend fun recursiveCountChildElements(rootDirectory: DocumentFile): Int {
if (!scope.isActive) { yield()
return 0
}
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
} }
private fun recursiveCopyVolume( private suspend fun recursiveCopyVolume(
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
dstRootDirectory?.let { it.value = dstDir } dstRootDirectory?.let { it.value = dstDir }
for (child in src.listFiles()) { for (child in src.listFiles()) {
if (!scope.isActive) { yield()
return null
}
if (child.isFile) { if (child.isFile) {
val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child val dstFile = dstDir.createFile("", child.name ?: return child) ?: return child
val outputStream = contentResolver.openOutputStream(dstFile.uri) val outputStream = contentResolver.openOutputStream(dstFile.uri)
@ -466,29 +698,25 @@ 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
} }
class CopyVolumeResult(val taskResult: TaskResult<DocumentFile?>, val dstRootDirectory: DocumentFile?) class CopyVolumeResult(val taskResult: TaskResult<out DocumentFile?>, val dstRootDirectory: DocumentFile?)
suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult = coroutineScope { suspend fun copyVolume(src: DocumentFile, dst: DocumentFile): CopyVolumeResult {
val notification = showNotification(R.string.copy_volume_notification, null)
val dstRootDirectory = ObjRef<DocumentFile?>(null) val dstRootDirectory = ObjRef<DocumentFile?>(null)
val task = async(Dispatchers.IO) { val result = globalTask(R.string.copy_volume_notification, null, { taskId ->
val total = recursiveCountChildElements(src, this) val total = recursiveCountChildElements(src)
if (isActive) { updateNotificationProgress(taskId, 0, total)
updateNotificationProgress(notification, 0, total) recursiveCopyVolume(src, dst, dstRootDirectory, taskId, total)
recursiveCopyVolume(src, dst, dstRootDirectory, notification, total, this) }, {
} else { dstRootDirectory.value?.delete()
null })
} return CopyVolumeResult(result, dstRootDirectory.value)
}
// treat cancellation as success
CopyVolumeResult(waitForTask(notification, task), dstRootDirectory.value)
} }
} }

View File

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

View File

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

View File

@ -1,8 +1,11 @@
package sushi.hardcore.droidfs.file_viewers 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

View File

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

View File

@ -18,10 +18,12 @@ 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.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() {
@ -40,7 +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(
intent.getIntExtra("volumeId", -1)
)!!
finishOnClose(encryptedVolume)
foldersFirst = sharedPrefs.getBoolean("folders_first", true) foldersFirst = sharedPrefs.getBoolean("folders_first", true)
windowInsetsController = WindowInsetsControllerCompat(window, window.decorView) windowInsetsController = WindowInsetsControllerCompat(window, window.decorView)
windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask -> windowInsetsController.addOnControllableInsetsChangedListener { _, typeMask ->
@ -173,11 +178,4 @@ abstract class FileViewerActivity: BaseActivity() {
protected fun goBackToExplorer() { protected fun goBackToExplorer() {
finish() finish()
} }
override fun onResume() {
super.onResume()
if (encryptedVolume.isClosed()) {
finish()
}
}
} }

View File

@ -1,16 +1,22 @@
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.media3.common.MediaItem
import com.google.android.exoplayer2.source.MediaSource import androidx.media3.common.PlaybackException
import com.google.android.exoplayer2.source.ProgressiveMediaSource import androidx.media3.common.Player
import com.google.android.exoplayer2.video.VideoSize import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.extractor.DefaultExtractorsFactory
import sushi.hardcore.droidfs.Constants import sushi.hardcore.droidfs.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

View File

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

View File

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

View File

@ -2,35 +2,47 @@ 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.PathUtils 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(
val errorCode: Int,
val errorStringId: Int,
val worthRetry: Boolean,
val volume: EncryptedVolume?,
) {
class Builder {
var errorCode = 0
var errorStringId = 0
var worthRetry = false
var volume: EncryptedVolume? = null
fun build() = InitResult(errorCode, errorStringId, worthRetry, volume)
}
}
companion object { companion object {
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> { * Get the type of a volume.
override fun createFromParcel(parcel: Parcel): EncryptedVolume { *
return when (parcel.readByte()) { * @return The volume type or -1 if the path is not recognized as a volume
GOCRYPTFS_VOLUME_TYPE -> GocryptfsVolume(parcel) */
CRYFS_VOLUME_TYPE -> CryfsVolume(parcel)
else -> throw invalidVolumeType()
}
}
override fun newArray(size: Int) = arrayOfNulls<EncryptedVolume>(size)
}
fun getVolumeType(path: String): Byte { fun getVolumeType(path: String): Byte {
return if (File(path, GocryptfsVolume.CONFIG_FILE_NAME).isFile) { return if (File(path, GocryptfsVolume.CONFIG_FILE_NAME).isFile) {
GOCRYPTFS_VOLUME_TYPE GOCRYPTFS_VOLUME_TYPE
@ -47,7 +59,7 @@ abstract class EncryptedVolume: Parcelable {
password: ByteArray?, password: ByteArray?,
givenHash: ByteArray?, givenHash: ByteArray?,
returnedHash: ObjRef<ByteArray?>? returnedHash: ObjRef<ByteArray?>?
): EncryptedVolume? { ): InitResult {
return when (volume.type) { return when (volume.type) {
GOCRYPTFS_VOLUME_TYPE -> { GOCRYPTFS_VOLUME_TYPE -> {
GocryptfsVolume.init( GocryptfsVolume.init(
@ -71,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
@ -86,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
} }
@ -139,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
} }
@ -202,25 +216,4 @@ abstract class EncryptedVolume: Parcelable {
} }
return result return result
} }
fun recursiveRemoveDirectory(path: String): String? {
readDir(path)?.let { elements ->
for (e in elements) {
val fullPath = PathUtils.pathJoin(path, e.name)
if (e.isDirectory) {
val result = recursiveRemoveDirectory(fullPath)
result?.let { return it }
} else {
if (!deleteFile(fullPath)) {
return fullPath
}
}
}
}
return if (!rmdir(path)) {
path
} else {
null
}
}
} }

View File

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

View File

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

View File

@ -0,0 +1,110 @@
package sushi.hardcore.droidfs.util
import android.Manifest
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import kotlin.reflect.KProperty
object AndroidUtils {
fun isServiceRunning(context: Context, serviceClass: Class<*>): Boolean {
val manager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
@Suppress("DEPRECATION")
for (service in manager.getRunningServices(Int.MAX_VALUE)) {
if (serviceClass.name == service.service.className) {
return true
}
}
return false
}
/**
* A [Manifest.permission.POST_NOTIFICATIONS] permission helper.
*
* Must be initialized before [Activity.onCreate].
*/
class NotificationPermissionHelper<out A: AppCompatActivity>(val activity: A) {
private var listener: ((Boolean) -> Unit)? = null
private val launcher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
listener?.invoke(granted)
listener = null
}
/**
* Ask for notification permission if required and run the provided callback.
*
* The callback is run as soon as the user dismisses the permission dialog,
* no matter if the permission has been granted or not.
*
* If this function is called again before the user answered the dialog from the
* previous call, the previous callback won't be triggered.
*
* @param onDialogDismiss argument set to `true` if the permission is granted or
* not required, `false` otherwise
*/
fun askAndRun(onDialogDismiss: (Boolean) -> Unit) {
assert(listener == null)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
listener = onDialogDismiss
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
}
onDialogDismiss(true)
}
}
/**
* Property delegate mirroring the state of a boolean value in shared preferences.
*
* [init] **must** be called before accessing the delegated property.
*/
class LiveBooleanPreference(
private val key: String,
private val defaultValue: Boolean = false,
private val onChange: ((value: Boolean) -> Unit)? = null
) {
private lateinit var sharedPreferences: SharedPreferences
private var value = defaultValue
private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == this.key) {
reload()
onChange?.invoke(value)
}
}
fun init(context: Context) = init(PreferenceManager.getDefaultSharedPreferences(context))
fun init(sharedPreferences: SharedPreferences) {
this.sharedPreferences = sharedPreferences
reload()
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
}
private fun reload() {
value = sharedPreferences.getBoolean(key, defaultValue)
}
operator fun getValue(thisRef: Any, property: KProperty<*>) = value
companion object {
fun init(context: Context, vararg liveBooleanPreferences: LiveBooleanPreference) {
init(PreferenceManager.getDefaultSharedPreferences(context), *liveBooleanPreferences)
}
fun init(sharedPreferences: SharedPreferences, vararg liveBooleanPreferences: LiveBooleanPreference) {
for (i in liveBooleanPreferences) {
i.init(sharedPreferences)
}
}
}
}
}

View File

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

View File

@ -0,0 +1,21 @@
package sushi.hardcore.droidfs.util
import android.app.Activity
import sushi.hardcore.droidfs.filesystems.EncryptedVolume
abstract class Observable<T> {
protected val observers = mutableListOf<T>()
fun observe(observer: T) {
observers.add(observer)
}
}
fun Activity.finishOnClose(encryptedVolume: EncryptedVolume) {
encryptedVolume.observe(object : EncryptedVolume.Observer {
override fun onClose() {
finish()
// no need to remove observer as the EncryptedVolume will be destroyed
}
})
}

View File

@ -3,6 +3,7 @@ package sushi.hardcore.droidfs.util
import android.content.ActivityNotFoundException import android.content.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
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 819 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF" <vector android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24" android: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>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="?attr/buttonBackgroundColor"/>
<corners android:radius="50dp"/>
</shape>
</item>
</selector>

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