Compare commits
No commits in common. "android15" and "android14" have entirely different histories.
341 changed files with 7739 additions and 15162 deletions
.cirrus.yml
.github
.idea/codeStyles
Android.bpCHANGELOG.mdREADME.mdapp
build.gradle.kts
build/generated/source/proto/debug/kotlin/com/stevesoltys/seedvault/proto
libs
src
androidTest/java/com/stevesoltys/seedvault
KoinInstrumentationTestApp.ktPluginTest.kt
backend/saf
e2e
LargeBackupTestBase.ktLargeRestoreTestBase.ktLargeTestBase.ktSeedvaultLargeTest.ktSeedvaultLargeTestResult.kt
impl
io
screen/impl
plugins/saf
transport/backup
worker
main
AndroidManifest.xml
assets
java/com/stevesoltys/seedvault
App.ktBackupMonitor.ktBackupStateManager.ktMemoryLogger.ktUsbIntentReceiver.kt
crypto
header
metadata
plugins
LegacyStoragePlugin.ktStoragePlugin.ktStoragePluginManager.ktStorageProperties.kt
saf
DocumentsProviderLegacyPlugin.ktDocumentsProviderModule.ktDocumentsProviderStoragePlugin.ktDocumentsStorage.ktSafFactory.ktSafHandler.ktSafStorage.ktSafStorageOptions.ktStorageRootResolver.kt
webdav
repo
AppBackupManager.ktBackupData.ktBackupReceiver.ktBlobCache.ktBlobCreator.ktChecker.ktCheckerResult.ktLoader.ktPaddedInputStream.ktPadding.ktPruner.ktRepoModule.ktSnapshotCreator.ktSnapshotCreatorFactory.ktSnapshotManager.kt
restore
AppDataRestoreManager.ktAppSelectionFragment.ktAppSelectionManager.ktFilesSelectionFragment.ktRecycleBackupFragment.ktRestorableBackup.ktRestoreActivity.ktRestoreFilesFragment.ktRestoreProgressFragment.ktRestoreService.ktRestoreSetAdapter.ktRestoreSetFragment.ktRestoreUiModule.ktRestoreViewModel.kt
install
settings
77
.cirrus.yml
77
.cirrus.yml
|
@ -1,66 +1,13 @@
|
||||||
container:
|
task:
|
||||||
image: ghcr.io/cirruslabs/android-sdk:34
|
name: Build with AOSP
|
||||||
kvm: true
|
only_if: $CIRRUS_PR_LABELS =~ ".*aosp-build.*"
|
||||||
cpu: 8
|
timeout_in: 70m
|
||||||
memory: 16G
|
container:
|
||||||
|
image: ubuntu:23.04
|
||||||
instrumentation_tests_task:
|
cpu: 8
|
||||||
name: "Cirrus CI Instrumentation Tests"
|
memory: 32G
|
||||||
start_avd_background_script:
|
build_script:
|
||||||
sdkmanager --install "system-images;android-34;default;x86_64" "emulator";
|
- ./.github/scripts/build_aosp.sh aosp_arm64 ap1a userdebug android-14.0.0_r29
|
||||||
echo no | avdmanager create avd -n seedvault -k "system-images;android-34;default;x86_64";
|
|
||||||
$ANDROID_HOME/emulator/emulator
|
|
||||||
-avd seedvault
|
|
||||||
-no-audio
|
|
||||||
-no-boot-anim
|
|
||||||
-gpu swiftshader_indirect
|
|
||||||
-no-snapshot
|
|
||||||
-no-window
|
|
||||||
-writable-system;
|
|
||||||
provision_avd_background_script:
|
|
||||||
wget https://github.com/seedvault-app/seedvault-test-data/releases/download/3/backup.tar.gz;
|
|
||||||
|
|
||||||
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
|
|
||||||
adb root;
|
|
||||||
sleep 5;
|
|
||||||
adb remount;
|
|
||||||
adb reboot;
|
|
||||||
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
|
|
||||||
adb root;
|
|
||||||
sleep 5;
|
|
||||||
adb remount;
|
|
||||||
sleep 5;
|
|
||||||
assemble_script:
|
|
||||||
./gradlew :app:assembleRelease :contacts:assembleRelease assembleAndroidTest
|
|
||||||
install_app_script:
|
|
||||||
timeout 180s bash -c 'while [[ -z $(adb shell mount | grep "/system " | grep "(rw,") ]]; do sleep 1; done;';
|
|
||||||
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
|
|
||||||
|
|
||||||
adb shell mkdir -p /sdcard/seedvault_baseline;
|
|
||||||
adb push backup.tar.gz /sdcard/seedvault_baseline/backup.tar.gz;
|
|
||||||
adb shell tar xzf /sdcard/seedvault_baseline/backup.tar.gz --directory=/sdcard/seedvault_baseline;
|
|
||||||
|
|
||||||
adb shell mkdir -p /system/priv-app/Seedvault;
|
|
||||||
adb push app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk;
|
|
||||||
adb push permissions_com.stevesoltys.seedvault.xml /system/etc/permissions/privapp-permissions-seedvault.xml;
|
|
||||||
adb push allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml;
|
|
||||||
|
|
||||||
adb shell mkdir -p /system/priv-app/ContactsBackup;
|
|
||||||
adb push contactsbackup/build/outputs/apk/release/contactsbackup-release.apk /system/priv-app/ContactsBackup/contactsbackup.apk;
|
|
||||||
adb push contactsbackup/default-permissions_org.calyxos.backup.contacts.xml /system/etc/default-permissions/default-permissions_org.calyxos.backup.contacts.xml;
|
|
||||||
|
|
||||||
adb shell bmgr enable true;
|
|
||||||
adb reboot;
|
|
||||||
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
|
|
||||||
adb shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport;
|
|
||||||
adb reboot;
|
|
||||||
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
|
|
||||||
run_large_tests_script: ./gradlew -Pandroid.testInstrumentationRunnerArguments.size=large :app:connectedAndroidTest
|
|
||||||
run_other_tests_script: ./gradlew -Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.LargeTest connectedAndroidTest
|
|
||||||
always:
|
always:
|
||||||
pull_screenshots_script:
|
seedvault_artifacts:
|
||||||
adb pull /sdcard/seedvault_test_results
|
path: Seedvault.apk
|
||||||
screenshots_artifacts:
|
|
||||||
path: "seedvault_test_results/**/*.mp4"
|
|
||||||
logcat_artifacts:
|
|
||||||
path: "seedvault_test_results/**/*.log"
|
|
||||||
|
|
4
.github/scripts/run_tests.sh
vendored
4
.github/scripts/run_tests.sh
vendored
|
@ -10,8 +10,10 @@ echo "Installing Seedvault app..."
|
||||||
./gradlew --stacktrace :app:installDebugAndroidTest
|
./gradlew --stacktrace :app:installDebugAndroidTest
|
||||||
sleep 60
|
sleep 60
|
||||||
|
|
||||||
|
D2D_BACKUP_TEST=$1
|
||||||
|
|
||||||
large_test_exit_code=0
|
large_test_exit_code=0
|
||||||
./gradlew --stacktrace -Pinstrumented_test_size=large :app:connectedAndroidTest || large_test_exit_code=$?
|
./gradlew --stacktrace -Pinstrumented_test_size=large -Pd2d_backup_test="$D2D_BACKUP_TEST" :app:connectedAndroidTest || large_test_exit_code=$?
|
||||||
|
|
||||||
adb pull /sdcard/seedvault_test_results
|
adb pull /sdcard/seedvault_test_results
|
||||||
|
|
||||||
|
|
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
@ -20,6 +20,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
android_target: [ 34 ]
|
android_target: [ 34 ]
|
||||||
emulator_type: [ aosp_atd ]
|
emulator_type: [ aosp_atd ]
|
||||||
|
d2d_backup_test: [ true, false ]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
@ -52,7 +53,7 @@ jobs:
|
||||||
disable-animations: true
|
disable-animations: true
|
||||||
script: |
|
script: |
|
||||||
./app/development/scripts/provision_emulator.sh "test" "system-images;android-${{ matrix.android_target }};${{ matrix.emulator_type }};x86_64"
|
./app/development/scripts/provision_emulator.sh "test" "system-images;android-${{ matrix.android_target }};${{ matrix.emulator_type }};x86_64"
|
||||||
./.github/scripts/run_tests.sh
|
./.github/scripts/run_tests.sh ${{ matrix.d2d_backup_test }}
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
|
|
7
.idea/codeStyles/Project.xml
generated
7
.idea/codeStyles/Project.xml
generated
|
@ -1,7 +1,12 @@
|
||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
<JetCodeStyleSettings>
|
<JetCodeStyleSettings>
|
||||||
<option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" />
|
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||||
|
<value />
|
||||||
|
</option>
|
||||||
|
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||||
|
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||||
|
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
</JetCodeStyleSettings>
|
</JetCodeStyleSettings>
|
||||||
<codeStyleSettings language="XML">
|
<codeStyleSettings language="XML">
|
||||||
|
|
26
Android.bp
26
Android.bp
|
@ -8,23 +8,12 @@ android_app {
|
||||||
srcs: [
|
srcs: [
|
||||||
"app/src/main/java/**/*.kt",
|
"app/src/main/java/**/*.kt",
|
||||||
"app/src/main/java/**/*.java",
|
"app/src/main/java/**/*.java",
|
||||||
"app/src/main/proto/*.proto",
|
|
||||||
// as of Android 15, there is no way to pass --kotlin_out to aprotoc compiler
|
|
||||||
"app/build/generated/source/proto/debug/kotlin/com/stevesoltys/seedvault/proto/*.kt",
|
|
||||||
],
|
],
|
||||||
resource_dirs: [
|
resource_dirs: [
|
||||||
"app/src/main/res",
|
"app/src/main/res",
|
||||||
],
|
],
|
||||||
asset_dirs: [
|
|
||||||
"app/src/main/assets"
|
|
||||||
],
|
|
||||||
proto: {
|
|
||||||
type: "lite",
|
|
||||||
local_include_dirs: ["app/src/main/proto"],
|
|
||||||
},
|
|
||||||
static_libs: [
|
static_libs: [
|
||||||
"kotlin-stdlib-jdk8",
|
"kotlin-stdlib-jdk8",
|
||||||
"libprotobuf-java-lite",
|
|
||||||
"androidx.core_core-ktx",
|
"androidx.core_core-ktx",
|
||||||
"androidx.fragment_fragment-ktx",
|
"androidx.fragment_fragment-ktx",
|
||||||
"androidx.activity_activity-ktx",
|
"androidx.activity_activity-ktx",
|
||||||
|
@ -37,23 +26,18 @@ android_app {
|
||||||
"com.google.android.material_material",
|
"com.google.android.material_material",
|
||||||
"kotlinx-coroutines-android",
|
"kotlinx-coroutines-android",
|
||||||
"kotlinx-coroutines-core",
|
"kotlinx-coroutines-core",
|
||||||
"seedvault-lib-kotlin-logging-jvm",
|
// storage backup lib
|
||||||
// app backup related libs
|
|
||||||
"seedvault-lib-protobuf-kotlin-lite",
|
|
||||||
"seedvault-logback-android",
|
|
||||||
"seedvault-lib-chunker",
|
|
||||||
"seedvault-lib-zstd-jni",
|
|
||||||
"okio-lib",
|
|
||||||
// our own gradle module libs
|
|
||||||
"seedvault-lib-core",
|
|
||||||
"seedvault-lib-storage",
|
"seedvault-lib-storage",
|
||||||
// koin
|
// koin
|
||||||
"seedvault-lib-koin-core-jvm", // did not manage to add this as transitive dependency
|
"seedvault-lib-koin-core-jvm", // did not manage to add this as transitive dependency
|
||||||
"seedvault-lib-koin-android",
|
"seedvault-lib-koin-android",
|
||||||
// bip39
|
// bip39
|
||||||
"seedvault-lib-kotlin-bip39",
|
"seedvault-lib-kotlin-bip39",
|
||||||
|
// WebDAV
|
||||||
|
"seedvault-lib-dav4jvm",
|
||||||
|
"seedvault-lib-okhttp",
|
||||||
|
"seedvault-lib-okio",
|
||||||
],
|
],
|
||||||
use_embedded_native_libs: true,
|
|
||||||
manifest: "app/src/main/AndroidManifest.xml",
|
manifest: "app/src/main/AndroidManifest.xml",
|
||||||
|
|
||||||
platform_apis: true,
|
platform_apis: true,
|
||||||
|
|
12
CHANGELOG.md
12
CHANGELOG.md
|
@ -1,15 +1,3 @@
|
||||||
## [15-5.0] - 2024-10-15
|
|
||||||
* First Android 15 release
|
|
||||||
* New backup format using compression and deduplication
|
|
||||||
* Can still restore old backups, but old Seedvault can't restore backups from this version
|
|
||||||
* Faster and more reliable backups making snapshots that can individually be restored
|
|
||||||
* Auto-cleaning of old backups
|
|
||||||
* All backups now mimic device-to-device (allowing backup for all apps)
|
|
||||||
* All backups now use a high per-app app quota
|
|
||||||
* App backup (for APKs) moved to expert settings
|
|
||||||
* Show more information for backups available to restore
|
|
||||||
* Fix "Waiting to back up..." showing for apps
|
|
||||||
|
|
||||||
## [14-4.1] - 2024-08-23
|
## [14-4.1] - 2024-08-23
|
||||||
* It is now possible to restore after setting up a profile
|
* It is now possible to restore after setting up a profile
|
||||||
* It is now possible to select what to restore (e.g. apps, files...)
|
* It is now possible to select what to restore (e.g. apps, files...)
|
||||||
|
|
53
README.md
53
README.md
|
@ -2,20 +2,14 @@
|
||||||
[](https://github.com/seedvault-app/seedvault/actions/workflows/build.yml)
|
[](https://github.com/seedvault-app/seedvault/actions/workflows/build.yml)
|
||||||
|
|
||||||
A backup application for the [Android Open Source Project](https://source.android.com/).
|
A backup application for the [Android Open Source Project](https://source.android.com/).
|
||||||
Needs to be [integrated](https://github.com/seedvault-app/seedvault/wiki/ROM-Integration)
|
|
||||||
in your Android ROM and **can not** be installed as a regular app.
|
|
||||||
|
|
||||||
If you are having an issue/question,
|
If you are having an issue/question, please look at our [FAQ](https://github.com/seedvault-app/seedvault/wiki/FAQ).
|
||||||
please look at our [FAQ](https://github.com/seedvault-app/seedvault/wiki/FAQ)
|
|
||||||
or [ask a new question](https://github.com/seedvault-app/seedvault/discussions).
|
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
* [Local Contacts Backup](contactsbackup) - an app that backs up local on-device contacts
|
* [Local Contacts Backup](contactsbackup) - an app that backs up local on-device contacts
|
||||||
* [File backup library](storage) - a library handling efficient backup of files
|
* [Storage library](storage) - a library handling efficient backup of files
|
||||||
([documentation](storage/doc/design.md))
|
|
||||||
* [Seedvault app](app) - the main app where all functionality comes together
|
* [Seedvault app](app) - the main app where all functionality comes together
|
||||||
([documentation](doc/README.md))
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Backup application data to a flash drive.
|
- Backup application data to a flash drive.
|
||||||
|
@ -25,27 +19,24 @@ or [ask a new question](https://github.com/seedvault-app/seedvault/discussions).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
SeedVault is developed along with AOSP releases.
|
SeedVault is developed along with AOSP releases
|
||||||
|
|
||||||
We update it every time Google releases a new Android version,
|
We update it every time Google releases a new Android version, make any changes required for basic functionality, and any improvements possible through API changes in the OS.
|
||||||
make any changes required for basic functionality,
|
|
||||||
and any improvements possible through API changes in the OS.
|
|
||||||
|
|
||||||
This means that for ROMs using SeedVault it's recommended
|
This means that for ROMs using SeedVault it's recommended to use the same branch as your android version
|
||||||
to use the same branch as your android version
|
|
||||||
|
|
||||||
- This current branch `android15` is meant for usage with Android 15
|
- This current branch `android14` is meant for usage with Android 14
|
||||||
- This is indicated by the version name starting with `15`,
|
- This is indicated by the version name starting with `14`, and the version code starting with `34` - the Android 14 API version
|
||||||
and the version code starting with `35` - the Android 15 API version
|
|
||||||
|
|
||||||
For older versions of Android,
|
For older versions of Android, check out [the branches](https://github.com/seedvault-app/seedvault/branches).
|
||||||
check out [the branches](https://github.com/seedvault-app/seedvault/branches).
|
|
||||||
|
|
||||||
Trying to use an older branch on a newer version may lead to issues
|
Trying to use an older branch on a newer version may lead to issues and is not something we can support.
|
||||||
and is not something we can support.
|
|
||||||
|
## Getting Started
|
||||||
|
- Check out [the wiki](https://github.com/seedvault-app/seedvault/wiki) for information on building the application with
|
||||||
|
AOSP.
|
||||||
|
|
||||||
## What makes this different?
|
## What makes this different?
|
||||||
|
|
||||||
This application is compiled with the operating system and does not require a rooted device for use.
|
This application is compiled with the operating system and does not require a rooted device for use.
|
||||||
It uses the same internal APIs as `adb backup` which is deprecated and thus needs a replacement.
|
It uses the same internal APIs as `adb backup` which is deprecated and thus needs a replacement.
|
||||||
|
|
||||||
|
@ -69,11 +60,9 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
|
||||||
## Contributing
|
## Contributing
|
||||||
Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault.
|
Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault.
|
||||||
|
|
||||||
See [DEVELOPMENT.md](app/development/DEVELOPMENT.md) for information
|
See [DEVELOPMENT.md](app/development/DEVELOPMENT.md) for information on developing Seedvault locally.
|
||||||
on developing Seedvault locally.
|
|
||||||
|
|
||||||
This project aims to adhere to the
|
This project aims to adhere to the [official Kotlin coding style](https://developer.android.com/kotlin/style-guide).
|
||||||
[official Kotlin coding style](https://developer.android.com/kotlin/style-guide).
|
|
||||||
|
|
||||||
## Third-party tools
|
## Third-party tools
|
||||||
|
|
||||||
|
@ -89,8 +78,7 @@ allows you to decrypt and inspect your backups from newer versions of Seedvault
|
||||||
It is currently work-in-progress.
|
It is currently work-in-progress.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
This application is available as open source under the terms
|
This application is available as open source under the terms of the [Apache-2.0 License](https://opensource.org/licenses/Apache-2.0).
|
||||||
of the [Apache-2.0 License](https://opensource.org/licenses/Apache-2.0).
|
|
||||||
|
|
||||||
## Funding
|
## Funding
|
||||||
|
|
||||||
|
@ -106,12 +94,3 @@ a fund established by [NLnet](https://nlnet.nl)
|
||||||
with financial support from the European Commission's Next Generation Internet programme,
|
with financial support from the European Commission's Next Generation Internet programme,
|
||||||
under the aegis of DG Communications Networks, Content and Technology
|
under the aegis of DG Communications Networks, Content and Technology
|
||||||
under grant agreement No 825310.
|
under grant agreement No 825310.
|
||||||
|
|
||||||
### NGI0 Entrust Fund
|
|
||||||
|
|
||||||
This project was funded through the
|
|
||||||
[NGI0 Entrust Fund](https://nlnet.nl/project/SeedVault-Integrity/),
|
|
||||||
a fund established by [NLnet](https://nlnet.nl)
|
|
||||||
with financial support from the European Commission's Next Generation Internet programme,
|
|
||||||
under the aegis of DG Communications Networks, Content and Technology
|
|
||||||
under grant agreement No 101069594.
|
|
||||||
|
|
|
@ -3,14 +3,12 @@
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
//
|
//
|
||||||
|
|
||||||
import com.google.protobuf.gradle.id
|
|
||||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.jetbrains.kotlin.android)
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
alias(libs.plugins.google.protobuf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val gitDescribe = {
|
val gitDescribe = {
|
||||||
|
@ -32,6 +30,16 @@ android {
|
||||||
versionNameSuffix = "-${gitDescribe()}"
|
versionNameSuffix = "-${gitDescribe()}"
|
||||||
testInstrumentationRunner = "com.stevesoltys.seedvault.KoinInstrumentationTestRunner"
|
testInstrumentationRunner = "com.stevesoltys.seedvault.KoinInstrumentationTestRunner"
|
||||||
testInstrumentationRunnerArguments["disableAnalytics"] = "true"
|
testInstrumentationRunnerArguments["disableAnalytics"] = "true"
|
||||||
|
|
||||||
|
if (project.hasProperty("instrumented_test_size")) {
|
||||||
|
val testSize = project.property("instrumented_test_size").toString()
|
||||||
|
println("Instrumented test size: $testSize")
|
||||||
|
|
||||||
|
testInstrumentationRunnerArguments["size"] = testSize
|
||||||
|
}
|
||||||
|
|
||||||
|
val d2dBackupTest = project.findProperty("d2d_backup_test")?.toString() ?: "true"
|
||||||
|
testInstrumentationRunnerArguments["d2d_backup_test"] = d2dBackupTest
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
@ -85,30 +93,6 @@ android {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
|
||||||
protoc {
|
|
||||||
artifact = if ("aarch64" == System.getProperty("os.arch")) {
|
|
||||||
// mac m1
|
|
||||||
"com.google.protobuf:protoc:${libs.versions.protobuf.get()}:osx-x86_64"
|
|
||||||
} else {
|
|
||||||
// other
|
|
||||||
"com.google.protobuf:protoc:${libs.versions.protobuf.get()}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
generateProtoTasks {
|
|
||||||
all().forEach { task ->
|
|
||||||
task.plugins {
|
|
||||||
id("java") {
|
|
||||||
option("lite")
|
|
||||||
}
|
|
||||||
id("kotlin") {
|
|
||||||
option("lite")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lint {
|
lint {
|
||||||
abortOnError = true
|
abortOnError = true
|
||||||
|
|
||||||
|
@ -122,7 +106,19 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
val aospLibs: FileTree by rootProject.extra
|
|
||||||
|
val aospLibs = fileTree("$projectDir/libs") {
|
||||||
|
// For more information about this module:
|
||||||
|
// https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507
|
||||||
|
// framework_intermediates/classes-header.jar works for gradle build as well,
|
||||||
|
// but not unit tests, so we use the actual classes (without updatable modules).
|
||||||
|
//
|
||||||
|
// out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
|
||||||
|
include("android.jar")
|
||||||
|
// out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art_intermediates/classes.jar
|
||||||
|
include("libcore.jar")
|
||||||
|
}
|
||||||
|
|
||||||
compileOnly(aospLibs)
|
compileOnly(aospLibs)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -148,15 +144,11 @@ dependencies {
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
implementation(libs.google.material)
|
implementation(libs.google.material)
|
||||||
|
|
||||||
implementation(libs.google.protobuf.javalite)
|
|
||||||
implementation(libs.google.tink.android)
|
implementation(libs.google.tink.android)
|
||||||
implementation(libs.kotlin.logging)
|
|
||||||
implementation(libs.squareup.okio)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage Dependencies
|
* Storage Dependencies
|
||||||
*/
|
*/
|
||||||
implementation(project(":core"))
|
|
||||||
implementation(project(":storage:lib"))
|
implementation(project(":storage:lib"))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -170,13 +162,9 @@ dependencies {
|
||||||
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.jar"))
|
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.jar"))
|
||||||
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.aar"))
|
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.aar"))
|
||||||
|
|
||||||
implementation(
|
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
|
||||||
fileTree("${rootProject.rootDir}/libs").include("protobuf-kotlin-lite-3.21.12.jar")
|
|
||||||
)
|
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
|
||||||
implementation(fileTree("${rootProject.rootDir}/libs").include("seedvault-chunker-0.1.jar"))
|
|
||||||
implementation(fileTree("${rootProject.rootDir}/libs").include("zstd-jni-1.5.6-5.aar"))
|
|
||||||
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.8.jar"))
|
|
||||||
implementation(fileTree("${rootProject.rootDir}/libs").include("logback-android-3.0.0.aar"))
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test Dependencies (do not concern the AOSP build)
|
* Test Dependencies (do not concern the AOSP build)
|
||||||
|
@ -186,7 +174,6 @@ dependencies {
|
||||||
// anything less than 'implementation' fails tests run with gradlew
|
// anything less than 'implementation' fails tests run with gradlew
|
||||||
testImplementation(aospLibs)
|
testImplementation(aospLibs)
|
||||||
testImplementation("androidx.test.ext:junit:1.1.5")
|
testImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
testImplementation("org.slf4j:slf4j-simple:2.0.3")
|
|
||||||
testImplementation("org.robolectric:robolectric:4.12.2")
|
testImplementation("org.robolectric:robolectric:4.12.2")
|
||||||
testImplementation("org.hamcrest:hamcrest:2.2")
|
testImplementation("org.hamcrest:hamcrest:2.2")
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
|
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
|
||||||
|
@ -197,12 +184,10 @@ dependencies {
|
||||||
)
|
)
|
||||||
testImplementation("app.cash.turbine:turbine:1.0.0")
|
testImplementation("app.cash.turbine:turbine:1.0.0")
|
||||||
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
|
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
|
||||||
testImplementation("com.github.luben:zstd-jni:1.5.6-5")
|
|
||||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
|
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
|
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
|
||||||
|
|
||||||
androidTestImplementation(aospLibs)
|
androidTestImplementation(aospLibs)
|
||||||
androidTestImplementation(kotlin("test"))
|
|
||||||
androidTestImplementation("androidx.test:runner:1.4.0")
|
androidTestImplementation("androidx.test:runner:1.4.0")
|
||||||
androidTestImplementation("androidx.test:rules:1.4.0")
|
androidTestImplementation("androidx.test:rules:1.4.0")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.3")
|
androidTestImplementation("androidx.test.ext:junit:1.1.3")
|
||||||
|
@ -212,7 +197,7 @@ dependencies {
|
||||||
|
|
||||||
gradle.projectsEvaluated {
|
gradle.projectsEvaluated {
|
||||||
tasks.withType(JavaCompile::class) {
|
tasks.withType(JavaCompile::class) {
|
||||||
options.compilerArgs.add("-Xbootclasspath/p:libs/aosp/android.jar:libs/aosp/libcore.jar")
|
options.compilerArgs.add("-Xbootclasspath/p:app/libs/android.jar:app/libs/libcore.jar")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,950 +0,0 @@
|
||||||
//Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
||||||
// source: snapshot.proto
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.proto;
|
|
||||||
|
|
||||||
@kotlin.jvm.JvmName("-initializesnapshot")
|
|
||||||
public inline fun snapshot(block: com.stevesoltys.seedvault.proto.SnapshotKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.newBuilder()).apply { block() }._build()
|
|
||||||
public object SnapshotKt {
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
|
||||||
public class Dsl private constructor(
|
|
||||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Builder
|
|
||||||
) {
|
|
||||||
public companion object {
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Builder): Dsl = Dsl(builder)
|
|
||||||
}
|
|
||||||
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot = _builder.build()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>uint32 version = 1;</code>
|
|
||||||
*/
|
|
||||||
public var version: kotlin.Int
|
|
||||||
@JvmName("getVersion")
|
|
||||||
get() = _builder.getVersion()
|
|
||||||
@JvmName("setVersion")
|
|
||||||
set(value) {
|
|
||||||
_builder.setVersion(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>uint32 version = 1;</code>
|
|
||||||
*/
|
|
||||||
public fun clearVersion() {
|
|
||||||
_builder.clearVersion()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>uint64 token = 2;</code>
|
|
||||||
*/
|
|
||||||
public var token: kotlin.Long
|
|
||||||
@JvmName("getToken")
|
|
||||||
get() = _builder.getToken()
|
|
||||||
@JvmName("setToken")
|
|
||||||
set(value) {
|
|
||||||
_builder.setToken(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>uint64 token = 2;</code>
|
|
||||||
*/
|
|
||||||
public fun clearToken() {
|
|
||||||
_builder.clearToken()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>string name = 3;</code>
|
|
||||||
*/
|
|
||||||
public var name: kotlin.String
|
|
||||||
@JvmName("getName")
|
|
||||||
get() = _builder.getName()
|
|
||||||
@JvmName("setName")
|
|
||||||
set(value) {
|
|
||||||
_builder.setName(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>string name = 3;</code>
|
|
||||||
*/
|
|
||||||
public fun clearName() {
|
|
||||||
_builder.clearName()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>string user = 4;</code>
|
|
||||||
*/
|
|
||||||
public var user: kotlin.String
|
|
||||||
@JvmName("getUser")
|
|
||||||
get() = _builder.getUser()
|
|
||||||
@JvmName("setUser")
|
|
||||||
set(value) {
|
|
||||||
_builder.setUser(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>string user = 4;</code>
|
|
||||||
*/
|
|
||||||
public fun clearUser() {
|
|
||||||
_builder.clearUser()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>string androidId = 5;</code>
|
|
||||||
*/
|
|
||||||
public var androidId: kotlin.String
|
|
||||||
@JvmName("getAndroidId")
|
|
||||||
get() = _builder.getAndroidId()
|
|
||||||
@JvmName("setAndroidId")
|
|
||||||
set(value) {
|
|
||||||
_builder.setAndroidId(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>string androidId = 5;</code>
|
|
||||||
*/
|
|
||||||
public fun clearAndroidId() {
|
|
||||||
_builder.clearAndroidId()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>uint32 sdkInt = 6;</code>
|
|
||||||
*/
|
|
||||||
public var sdkInt: kotlin.Int
|
|
||||||
@JvmName("getSdkInt")
|
|
||||||
get() = _builder.getSdkInt()
|
|
||||||
@JvmName("setSdkInt")
|
|
||||||
set(value) {
|
|
||||||
_builder.setSdkInt(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>uint32 sdkInt = 6;</code>
|
|
||||||
*/
|
|
||||||
public fun clearSdkInt() {
|
|
||||||
_builder.clearSdkInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>string androidIncremental = 7;</code>
|
|
||||||
*/
|
|
||||||
public var androidIncremental: kotlin.String
|
|
||||||
@JvmName("getAndroidIncremental")
|
|
||||||
get() = _builder.getAndroidIncremental()
|
|
||||||
@JvmName("setAndroidIncremental")
|
|
||||||
set(value) {
|
|
||||||
_builder.setAndroidIncremental(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>string androidIncremental = 7;</code>
|
|
||||||
*/
|
|
||||||
public fun clearAndroidIncremental() {
|
|
||||||
_builder.clearAndroidIncremental()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>bool d2d = 8;</code>
|
|
||||||
*/
|
|
||||||
public var d2D: kotlin.Boolean
|
|
||||||
@JvmName("getD2D")
|
|
||||||
get() = _builder.getD2D()
|
|
||||||
@JvmName("setD2D")
|
|
||||||
set(value) {
|
|
||||||
_builder.setD2D(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>bool d2d = 8;</code>
|
|
||||||
*/
|
|
||||||
public fun clearD2D() {
|
|
||||||
_builder.clearD2D()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An uninstantiable, behaviorless type to represent the field in
|
|
||||||
* generics.
|
|
||||||
*/
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
public class AppsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
|
||||||
*/
|
|
||||||
public val apps: com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("getAppsMap")
|
|
||||||
get() = com.google.protobuf.kotlin.DslMap(
|
|
||||||
_builder.getAppsMap()
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
|
||||||
*/
|
|
||||||
@JvmName("putApps")
|
|
||||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
|
||||||
.put(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.App) {
|
|
||||||
_builder.putApps(key, value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("setApps")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
|
||||||
.set(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.App) {
|
|
||||||
put(key, value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("removeApps")
|
|
||||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
|
||||||
.remove(key: kotlin.String) {
|
|
||||||
_builder.removeApps(key)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("putAllApps")
|
|
||||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
|
||||||
.putAll(map: kotlin.collections.Map<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App>) {
|
|
||||||
_builder.putAllApps(map)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("clearApps")
|
|
||||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
|
||||||
.clear() {
|
|
||||||
_builder.clearApps()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An uninstantiable, behaviorless type to represent the field in
|
|
||||||
* generics.
|
|
||||||
*/
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
public class IconChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
|
||||||
/**
|
|
||||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
|
||||||
*/
|
|
||||||
public val iconChunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
get() = com.google.protobuf.kotlin.DslList(
|
|
||||||
_builder.getIconChunkIdsList()
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
|
||||||
* @param value The iconChunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addIconChunkIds")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
|
|
||||||
_builder.addIconChunkIds(value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
|
||||||
* @param value The iconChunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignIconChunkIds")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
|
||||||
add(value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
|
||||||
* @param values The iconChunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addAllIconChunkIds")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
|
||||||
_builder.addAllIconChunkIds(values)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
|
||||||
* @param values The iconChunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignAllIconChunkIds")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
|
||||||
addAll(values)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
|
||||||
* @param index The index to set the value at.
|
|
||||||
* @param value The iconChunkIds to set.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("setIconChunkIds")
|
|
||||||
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
|
||||||
_builder.setIconChunkIds(index, value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes iconChunkIds = 10;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("clearIconChunkIds")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.clear() {
|
|
||||||
_builder.clearIconChunkIds()
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* An uninstantiable, behaviorless type to represent the field in
|
|
||||||
* generics.
|
|
||||||
*/
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
public class BlobsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
|
||||||
*/
|
|
||||||
public val blobs: com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("getBlobsMap")
|
|
||||||
get() = com.google.protobuf.kotlin.DslMap(
|
|
||||||
_builder.getBlobsMap()
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
|
||||||
*/
|
|
||||||
@JvmName("putBlobs")
|
|
||||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
|
||||||
.put(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.Blob) {
|
|
||||||
_builder.putBlobs(key, value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("setBlobs")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
|
||||||
.set(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.Blob) {
|
|
||||||
put(key, value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("removeBlobs")
|
|
||||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
|
||||||
.remove(key: kotlin.String) {
|
|
||||||
_builder.removeBlobs(key)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("putAllBlobs")
|
|
||||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
|
||||||
.putAll(map: kotlin.collections.Map<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob>) {
|
|
||||||
_builder.putAllBlobs(map)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@JvmName("clearBlobs")
|
|
||||||
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
|
||||||
.clear() {
|
|
||||||
_builder.clearBlobs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@kotlin.jvm.JvmName("-initializeapp")
|
|
||||||
public inline fun app(block: com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.App =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.App.newBuilder()).apply { block() }._build()
|
|
||||||
public object AppKt {
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
|
||||||
public class Dsl private constructor(
|
|
||||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.App.Builder
|
|
||||||
) {
|
|
||||||
public companion object {
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.App.Builder): Dsl = Dsl(builder)
|
|
||||||
}
|
|
||||||
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.App = _builder.build()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>uint64 time = 1;</code>
|
|
||||||
*/
|
|
||||||
public var time: kotlin.Long
|
|
||||||
@JvmName("getTime")
|
|
||||||
get() = _builder.getTime()
|
|
||||||
@JvmName("setTime")
|
|
||||||
set(value) {
|
|
||||||
_builder.setTime(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>uint64 time = 1;</code>
|
|
||||||
*/
|
|
||||||
public fun clearTime() {
|
|
||||||
_builder.clearTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.BackupType type = 2;</code>
|
|
||||||
*/
|
|
||||||
public var type: com.stevesoltys.seedvault.proto.Snapshot.BackupType
|
|
||||||
@JvmName("getType")
|
|
||||||
get() = _builder.getType()
|
|
||||||
@JvmName("setType")
|
|
||||||
set(value) {
|
|
||||||
_builder.setType(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.BackupType type = 2;</code>
|
|
||||||
*/
|
|
||||||
public fun clearType() {
|
|
||||||
_builder.clearType()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>string name = 3;</code>
|
|
||||||
*/
|
|
||||||
public var name: kotlin.String
|
|
||||||
@JvmName("getName")
|
|
||||||
get() = _builder.getName()
|
|
||||||
@JvmName("setName")
|
|
||||||
set(value) {
|
|
||||||
_builder.setName(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>string name = 3;</code>
|
|
||||||
*/
|
|
||||||
public fun clearName() {
|
|
||||||
_builder.clearName()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>bool system = 4;</code>
|
|
||||||
*/
|
|
||||||
public var system: kotlin.Boolean
|
|
||||||
@JvmName("getSystem")
|
|
||||||
get() = _builder.getSystem()
|
|
||||||
@JvmName("setSystem")
|
|
||||||
set(value) {
|
|
||||||
_builder.setSystem(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>bool system = 4;</code>
|
|
||||||
*/
|
|
||||||
public fun clearSystem() {
|
|
||||||
_builder.clearSystem()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>bool launchableSystemApp = 5;</code>
|
|
||||||
*/
|
|
||||||
public var launchableSystemApp: kotlin.Boolean
|
|
||||||
@JvmName("getLaunchableSystemApp")
|
|
||||||
get() = _builder.getLaunchableSystemApp()
|
|
||||||
@JvmName("setLaunchableSystemApp")
|
|
||||||
set(value) {
|
|
||||||
_builder.setLaunchableSystemApp(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>bool launchableSystemApp = 5;</code>
|
|
||||||
*/
|
|
||||||
public fun clearLaunchableSystemApp() {
|
|
||||||
_builder.clearLaunchableSystemApp()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An uninstantiable, behaviorless type to represent the field in
|
|
||||||
* generics.
|
|
||||||
*/
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
public class ChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
|
||||||
/**
|
|
||||||
* <code>repeated bytes chunkIds = 6;</code>
|
|
||||||
*/
|
|
||||||
public val chunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
get() = com.google.protobuf.kotlin.DslList(
|
|
||||||
_builder.getChunkIdsList()
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* <code>repeated bytes chunkIds = 6;</code>
|
|
||||||
* @param value The chunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addChunkIds")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
|
|
||||||
_builder.addChunkIds(value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 6;</code>
|
|
||||||
* @param value The chunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignChunkIds")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
|
||||||
add(value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 6;</code>
|
|
||||||
* @param values The chunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addAllChunkIds")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
|
||||||
_builder.addAllChunkIds(values)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 6;</code>
|
|
||||||
* @param values The chunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignAllChunkIds")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
|
||||||
addAll(values)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 6;</code>
|
|
||||||
* @param index The index to set the value at.
|
|
||||||
* @param value The chunkIds to set.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("setChunkIds")
|
|
||||||
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
|
||||||
_builder.setChunkIds(index, value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 6;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("clearChunkIds")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.clear() {
|
|
||||||
_builder.clearChunkIds()
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
|
|
||||||
*/
|
|
||||||
public var apk: com.stevesoltys.seedvault.proto.Snapshot.Apk
|
|
||||||
@JvmName("getApk")
|
|
||||||
get() = _builder.getApk()
|
|
||||||
@JvmName("setApk")
|
|
||||||
set(value) {
|
|
||||||
_builder.setApk(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
|
|
||||||
*/
|
|
||||||
public fun clearApk() {
|
|
||||||
_builder.clearApk()
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
|
|
||||||
* @return Whether the apk field is set.
|
|
||||||
*/
|
|
||||||
public fun hasApk(): kotlin.Boolean {
|
|
||||||
return _builder.hasApk()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>uint64 size = 8;</code>
|
|
||||||
*/
|
|
||||||
public var size: kotlin.Long
|
|
||||||
@JvmName("getSize")
|
|
||||||
get() = _builder.getSize()
|
|
||||||
@JvmName("setSize")
|
|
||||||
set(value) {
|
|
||||||
_builder.setSize(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>uint64 size = 8;</code>
|
|
||||||
*/
|
|
||||||
public fun clearSize() {
|
|
||||||
_builder.clearSize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@kotlin.jvm.JvmName("-initializeapk")
|
|
||||||
public inline fun apk(block: com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Apk =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Apk.newBuilder()).apply { block() }._build()
|
|
||||||
public object ApkKt {
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
|
||||||
public class Dsl private constructor(
|
|
||||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Apk.Builder
|
|
||||||
) {
|
|
||||||
public companion object {
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Apk.Builder): Dsl = Dsl(builder)
|
|
||||||
}
|
|
||||||
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Apk = _builder.build()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <pre>
|
|
||||||
**
|
|
||||||
* Attention: Has default value of 0
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* <code>uint64 versionCode = 1;</code>
|
|
||||||
*/
|
|
||||||
public var versionCode: kotlin.Long
|
|
||||||
@JvmName("getVersionCode")
|
|
||||||
get() = _builder.getVersionCode()
|
|
||||||
@JvmName("setVersionCode")
|
|
||||||
set(value) {
|
|
||||||
_builder.setVersionCode(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <pre>
|
|
||||||
**
|
|
||||||
* Attention: Has default value of 0
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* <code>uint64 versionCode = 1;</code>
|
|
||||||
*/
|
|
||||||
public fun clearVersionCode() {
|
|
||||||
_builder.clearVersionCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>string installer = 2;</code>
|
|
||||||
*/
|
|
||||||
public var installer: kotlin.String
|
|
||||||
@JvmName("getInstaller")
|
|
||||||
get() = _builder.getInstaller()
|
|
||||||
@JvmName("setInstaller")
|
|
||||||
set(value) {
|
|
||||||
_builder.setInstaller(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>string installer = 2;</code>
|
|
||||||
*/
|
|
||||||
public fun clearInstaller() {
|
|
||||||
_builder.clearInstaller()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An uninstantiable, behaviorless type to represent the field in
|
|
||||||
* generics.
|
|
||||||
*/
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
public class SignaturesProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
|
||||||
/**
|
|
||||||
* <code>repeated bytes signatures = 3;</code>
|
|
||||||
*/
|
|
||||||
public val signatures: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
get() = com.google.protobuf.kotlin.DslList(
|
|
||||||
_builder.getSignaturesList()
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* <code>repeated bytes signatures = 3;</code>
|
|
||||||
* @param value The signatures to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addSignatures")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.add(value: com.google.protobuf.ByteString) {
|
|
||||||
_builder.addSignatures(value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes signatures = 3;</code>
|
|
||||||
* @param value The signatures to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignSignatures")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
|
||||||
add(value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes signatures = 3;</code>
|
|
||||||
* @param values The signatures to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addAllSignatures")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
|
||||||
_builder.addAllSignatures(values)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes signatures = 3;</code>
|
|
||||||
* @param values The signatures to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignAllSignatures")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
|
||||||
addAll(values)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes signatures = 3;</code>
|
|
||||||
* @param index The index to set the value at.
|
|
||||||
* @param value The signatures to set.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("setSignatures")
|
|
||||||
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
|
||||||
_builder.setSignatures(index, value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes signatures = 3;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("clearSignatures")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.clear() {
|
|
||||||
_builder.clearSignatures()
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* An uninstantiable, behaviorless type to represent the field in
|
|
||||||
* generics.
|
|
||||||
*/
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
public class SplitsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
|
||||||
/**
|
|
||||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
|
||||||
*/
|
|
||||||
public val splits: com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
get() = com.google.protobuf.kotlin.DslList(
|
|
||||||
_builder.getSplitsList()
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
|
||||||
* @param value The splits to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addSplits")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.add(value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
|
|
||||||
_builder.addSplits(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
|
||||||
* @param value The splits to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignSplits")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.plusAssign(value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
|
|
||||||
add(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
|
||||||
* @param values The splits to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addAllSplits")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.addAll(values: kotlin.collections.Iterable<com.stevesoltys.seedvault.proto.Snapshot.Split>) {
|
|
||||||
_builder.addAllSplits(values)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
|
||||||
* @param values The splits to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignAllSplits")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.plusAssign(values: kotlin.collections.Iterable<com.stevesoltys.seedvault.proto.Snapshot.Split>) {
|
|
||||||
addAll(values)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
|
||||||
* @param index The index to set the value at.
|
|
||||||
* @param value The splits to set.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("setSplits")
|
|
||||||
public operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.set(index: kotlin.Int, value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
|
|
||||||
_builder.setSplits(index, value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("clearSplits")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.clear() {
|
|
||||||
_builder.clearSplits()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@kotlin.jvm.JvmName("-initializesplit")
|
|
||||||
public inline fun split(block: com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Split =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Split.newBuilder()).apply { block() }._build()
|
|
||||||
public object SplitKt {
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
|
||||||
public class Dsl private constructor(
|
|
||||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Split.Builder
|
|
||||||
) {
|
|
||||||
public companion object {
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Split.Builder): Dsl = Dsl(builder)
|
|
||||||
}
|
|
||||||
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Split = _builder.build()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>string name = 1;</code>
|
|
||||||
*/
|
|
||||||
public var name: kotlin.String
|
|
||||||
@JvmName("getName")
|
|
||||||
get() = _builder.getName()
|
|
||||||
@JvmName("setName")
|
|
||||||
set(value) {
|
|
||||||
_builder.setName(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>string name = 1;</code>
|
|
||||||
*/
|
|
||||||
public fun clearName() {
|
|
||||||
_builder.clearName()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An uninstantiable, behaviorless type to represent the field in
|
|
||||||
* generics.
|
|
||||||
*/
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
public class ChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
|
||||||
/**
|
|
||||||
* <code>repeated bytes chunkIds = 2;</code>
|
|
||||||
*/
|
|
||||||
public val chunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
get() = com.google.protobuf.kotlin.DslList(
|
|
||||||
_builder.getChunkIdsList()
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* <code>repeated bytes chunkIds = 2;</code>
|
|
||||||
* @param value The chunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addChunkIds")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
|
|
||||||
_builder.addChunkIds(value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 2;</code>
|
|
||||||
* @param value The chunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignChunkIds")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
|
||||||
add(value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 2;</code>
|
|
||||||
* @param values The chunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("addAllChunkIds")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
|
||||||
_builder.addAllChunkIds(values)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 2;</code>
|
|
||||||
* @param values The chunkIds to add.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("plusAssignAllChunkIds")
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
|
||||||
addAll(values)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 2;</code>
|
|
||||||
* @param index The index to set the value at.
|
|
||||||
* @param value The chunkIds to set.
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("setChunkIds")
|
|
||||||
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
|
||||||
_builder.setChunkIds(index, value)
|
|
||||||
}/**
|
|
||||||
* <code>repeated bytes chunkIds = 2;</code>
|
|
||||||
*/
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.jvm.JvmName("clearChunkIds")
|
|
||||||
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.clear() {
|
|
||||||
_builder.clearChunkIds()
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
@kotlin.jvm.JvmName("-initializeblob")
|
|
||||||
public inline fun blob(block: com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Blob =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Blob.newBuilder()).apply { block() }._build()
|
|
||||||
public object BlobKt {
|
|
||||||
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
|
||||||
@com.google.protobuf.kotlin.ProtoDslMarker
|
|
||||||
public class Dsl private constructor(
|
|
||||||
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Blob.Builder
|
|
||||||
) {
|
|
||||||
public companion object {
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Blob.Builder): Dsl = Dsl(builder)
|
|
||||||
}
|
|
||||||
|
|
||||||
@kotlin.jvm.JvmSynthetic
|
|
||||||
@kotlin.PublishedApi
|
|
||||||
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Blob = _builder.build()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>bytes id = 1;</code>
|
|
||||||
*/
|
|
||||||
public var id: com.google.protobuf.ByteString
|
|
||||||
@JvmName("getId")
|
|
||||||
get() = _builder.getId()
|
|
||||||
@JvmName("setId")
|
|
||||||
set(value) {
|
|
||||||
_builder.setId(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>bytes id = 1;</code>
|
|
||||||
*/
|
|
||||||
public fun clearId() {
|
|
||||||
_builder.clearId()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>uint32 length = 2;</code>
|
|
||||||
*/
|
|
||||||
public var length: kotlin.Int
|
|
||||||
@JvmName("getLength")
|
|
||||||
get() = _builder.getLength()
|
|
||||||
@JvmName("setLength")
|
|
||||||
set(value) {
|
|
||||||
_builder.setLength(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>uint32 length = 2;</code>
|
|
||||||
*/
|
|
||||||
public fun clearLength() {
|
|
||||||
_builder.clearLength()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <code>uint32 uncompressedLength = 3;</code>
|
|
||||||
*/
|
|
||||||
public var uncompressedLength: kotlin.Int
|
|
||||||
@JvmName("getUncompressedLength")
|
|
||||||
get() = _builder.getUncompressedLength()
|
|
||||||
@JvmName("setUncompressedLength")
|
|
||||||
set(value) {
|
|
||||||
_builder.setUncompressedLength(value)
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* <code>uint32 uncompressedLength = 3;</code>
|
|
||||||
*/
|
|
||||||
public fun clearUncompressedLength() {
|
|
||||||
_builder.clearUncompressedLength()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
|
||||||
|
|
||||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.App.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.App =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
|
||||||
|
|
||||||
public val com.stevesoltys.seedvault.proto.Snapshot.AppOrBuilder.apkOrNull: com.stevesoltys.seedvault.proto.Snapshot.Apk?
|
|
||||||
get() = if (hasApk()) getApk() else null
|
|
||||||
|
|
||||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Apk.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Apk =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
|
||||||
|
|
||||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Split.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Split =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
|
||||||
|
|
||||||
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Blob.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Blob =
|
|
||||||
com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
|
||||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -32,16 +32,16 @@ class KoinInstrumentationTestApp : App() {
|
||||||
val testModule = module {
|
val testModule = module {
|
||||||
val context = this@KoinInstrumentationTestApp
|
val context = this@KoinInstrumentationTestApp
|
||||||
|
|
||||||
single { spyk(PackageService(context, get(), get())) }
|
single { spyk(PackageService(context, get(), get(), get())) }
|
||||||
single { spyk(SettingsManager(context)) }
|
single { spyk(SettingsManager(context)) }
|
||||||
|
|
||||||
single { spyk(BackupNotificationManager(context)) }
|
single { spyk(BackupNotificationManager(context)) }
|
||||||
single { spyk(FullBackup(get(), get(), get(), get())) }
|
single { spyk(FullBackup(get(), get(), get(), get(), get())) }
|
||||||
single { spyk(KVBackup(get(), get(), get())) }
|
single { spyk(KVBackup(get(), get(), get(), get(), get(), get())) }
|
||||||
single { spyk(InputFactory()) }
|
single { spyk(InputFactory()) }
|
||||||
|
|
||||||
single { spyk(FullRestore(get(), get(), get(), get(), get(), get())) }
|
single { spyk(FullRestore(get(), get(), get(), get(), get())) }
|
||||||
single { spyk(KVRestore(get(), get(), get(), get(), get(), get(), get())) }
|
single { spyk(KVRestore(get(), get(), get(), get(), get(), get())) }
|
||||||
single { spyk(OutputFactory()) }
|
single { spyk(OutputFactory()) }
|
||||||
|
|
||||||
viewModel {
|
viewModel {
|
||||||
|
@ -53,11 +53,10 @@ class KoinInstrumentationTestApp : App() {
|
||||||
keyManager = get(),
|
keyManager = get(),
|
||||||
backupManager = get(),
|
backupManager = get(),
|
||||||
restoreCoordinator = get(),
|
restoreCoordinator = get(),
|
||||||
appBackupManager = get(),
|
|
||||||
apkRestore = get(),
|
apkRestore = get(),
|
||||||
iconManager = get(),
|
iconManager = get(),
|
||||||
storageBackup = get(),
|
storageBackup = get(),
|
||||||
backendManager = get(),
|
pluginManager = get(),
|
||||||
fileSelectionManager = get(),
|
fileSelectionManager = get(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,22 +5,26 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
import androidx.test.core.content.pm.PackageInfoBuilder
|
import androidx.test.core.content.pm.PackageInfoBuilder
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.backend.saf.DocumentsProviderLegacyPlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
import com.stevesoltys.seedvault.backend.saf.DocumentsStorage
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderLegacyPlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.deleteContents
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
@ -38,10 +42,11 @@ class PluginTest : KoinComponent {
|
||||||
private val mockedSettingsManager: SettingsManager = mockk()
|
private val mockedSettingsManager: SettingsManager = mockk()
|
||||||
private val storage = DocumentsStorage(
|
private val storage = DocumentsStorage(
|
||||||
appContext = context,
|
appContext = context,
|
||||||
safStorage = settingsManager.getSafProperties() ?: error("No SAF storage"),
|
settingsManager = mockedSettingsManager,
|
||||||
|
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val backend = SafBackend(context, storage.safStorage)
|
private val storagePlugin: StoragePlugin<Uri> = DocumentsProviderStoragePlugin(context, storage)
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
|
private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
|
||||||
|
@ -54,30 +59,30 @@ class PluginTest : KoinComponent {
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() = runBlocking {
|
fun setup() = runBlocking {
|
||||||
every {
|
every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage()
|
||||||
mockedSettingsManager.getSafProperties()
|
storage.rootBackupDir?.deleteContents(context)
|
||||||
} returns settingsManager.getSafProperties()
|
?: error("Select a storage location in the app first!")
|
||||||
backend.removeAll()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() = runBlocking {
|
fun tearDown() = runBlocking {
|
||||||
backend.removeAll()
|
storage.rootBackupDir?.deleteContents(context)
|
||||||
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testProviderPackageName() {
|
fun testProviderPackageName() {
|
||||||
assertNotNull(backend.providerPackageName)
|
assertNotNull(storagePlugin.providerPackageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTest() = runBlocking(Dispatchers.IO) {
|
fun testTest() = runBlocking(Dispatchers.IO) {
|
||||||
assertTrue(backend.test())
|
assertTrue(storagePlugin.test())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testGetFreeSpace() = runBlocking(Dispatchers.IO) {
|
fun testGetFreeSpace() = runBlocking(Dispatchers.IO) {
|
||||||
val freeBytes = backend.getFreeSpace() ?: error("no free space retrieved")
|
val freeBytes = storagePlugin.getFreeSpace() ?: error("no free space retrieved")
|
||||||
assertTrue(freeBytes > 0)
|
assertTrue(freeBytes > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -91,66 +96,80 @@ class PluginTest : KoinComponent {
|
||||||
@Test
|
@Test
|
||||||
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
|
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
|
||||||
// no backups available initially
|
// no backups available initially
|
||||||
assertEquals(0, backend.getAvailableBackupFileHandles().toList().size)
|
assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
|
|
||||||
// prepare returned tokens requested when initializing device
|
// prepare returned tokens requested when initializing device
|
||||||
every { mockedSettingsManager.token } returnsMany listOf(token, token + 1, token + 1)
|
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
|
||||||
|
|
||||||
|
// start new restore set and initialize device afterwards
|
||||||
|
storagePlugin.startNewRestoreSet(token)
|
||||||
|
storagePlugin.initializeDevice()
|
||||||
|
|
||||||
// write metadata (needed for backup to be recognized)
|
// write metadata (needed for backup to be recognized)
|
||||||
backend.save(LegacyAppBackupFile.Metadata(token))
|
storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA)
|
||||||
.writeAndClose(getRandomByteArray())
|
.writeAndClose(getRandomByteArray())
|
||||||
|
|
||||||
// one backup available now
|
// one backup available now
|
||||||
assertEquals(1, backend.getAvailableBackupFileHandles().toList().size)
|
assertEquals(1, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
|
|
||||||
// initializing again (with another restore set) does add a restore set
|
// initializing again (with another restore set) does add a restore set
|
||||||
backend.save(LegacyAppBackupFile.Metadata(token + 1))
|
storagePlugin.startNewRestoreSet(token + 1)
|
||||||
|
storagePlugin.initializeDevice()
|
||||||
|
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
|
||||||
.writeAndClose(getRandomByteArray())
|
.writeAndClose(getRandomByteArray())
|
||||||
assertEquals(2, backend.getAvailableBackupFileHandles().toList().size)
|
assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
|
|
||||||
// initializing again (without new restore set) doesn't change number of restore sets
|
// initializing again (without new restore set) doesn't change number of restore sets
|
||||||
backend.save(LegacyAppBackupFile.Metadata(token + 1))
|
storagePlugin.initializeDevice()
|
||||||
|
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
|
||||||
.writeAndClose(getRandomByteArray())
|
.writeAndClose(getRandomByteArray())
|
||||||
assertEquals(2, backend.getAvailableBackupFileHandles().toList().size)
|
assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
|
|
||||||
|
// ensure that the new backup dir exist
|
||||||
|
assertTrue(storage.currentSetDir!!.exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
|
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
|
||||||
every { mockedSettingsManager.token } returns token
|
every { mockedSettingsManager.getToken() } returns token
|
||||||
|
|
||||||
|
storagePlugin.startNewRestoreSet(token)
|
||||||
|
storagePlugin.initializeDevice()
|
||||||
|
|
||||||
// write metadata
|
// write metadata
|
||||||
val metadata = getRandomByteArray()
|
val metadata = getRandomByteArray()
|
||||||
backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata)
|
storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA).writeAndClose(metadata)
|
||||||
|
|
||||||
// get available backups, expect only one with our token and no error
|
// get available backups, expect only one with our token and no error
|
||||||
var availableBackups = backend.getAvailableBackupFileHandles().toList()
|
var availableBackups = storagePlugin.getAvailableBackups()?.toList()
|
||||||
|
check(availableBackups != null)
|
||||||
assertEquals(1, availableBackups.size)
|
assertEquals(1, availableBackups.size)
|
||||||
var backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata
|
assertEquals(token, availableBackups[0].token)
|
||||||
assertEquals(token, backupHandle.token)
|
|
||||||
|
|
||||||
// read metadata matches what was written earlier
|
// read metadata matches what was written earlier
|
||||||
assertReadEquals(metadata, backend.load(backupHandle))
|
assertReadEquals(metadata, availableBackups[0].inputStreamRetriever())
|
||||||
|
|
||||||
// initializing again (without changing storage) keeps restore set with same token
|
// initializing again (without changing storage) keeps restore set with same token
|
||||||
backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata)
|
storagePlugin.initializeDevice()
|
||||||
availableBackups = backend.getAvailableBackupFileHandles().toList()
|
storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA).writeAndClose(metadata)
|
||||||
|
availableBackups = storagePlugin.getAvailableBackups()?.toList()
|
||||||
|
check(availableBackups != null)
|
||||||
assertEquals(1, availableBackups.size)
|
assertEquals(1, availableBackups.size)
|
||||||
backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata
|
assertEquals(token, availableBackups[0].token)
|
||||||
assertEquals(token, backupHandle.token)
|
|
||||||
|
|
||||||
// metadata hasn't changed
|
// metadata hasn't changed
|
||||||
assertReadEquals(metadata, backend.load(backupHandle))
|
assertReadEquals(metadata, availableBackups[0].inputStreamRetriever())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@Suppress("Deprecation")
|
||||||
fun v0testApkWriteRead() = runBlocking {
|
fun v0testApkWriteRead() = runBlocking {
|
||||||
// initialize storage with given token
|
// initialize storage with given token
|
||||||
initStorage(token)
|
initStorage(token)
|
||||||
|
|
||||||
// write random bytes as APK
|
// write random bytes as APK
|
||||||
val apk1 = getRandomByteArray(1337 * 1024)
|
val apk1 = getRandomByteArray(1337 * 1024)
|
||||||
backend.save(LegacyAppBackupFile.Blob(token, "${packageInfo.packageName}.apk"))
|
storagePlugin.getOutputStream(token, "${packageInfo.packageName}.apk").writeAndClose(apk1)
|
||||||
.writeAndClose(apk1)
|
|
||||||
|
|
||||||
// assert that read APK bytes match what was written
|
// assert that read APK bytes match what was written
|
||||||
assertReadEquals(
|
assertReadEquals(
|
||||||
|
@ -162,7 +181,7 @@ class PluginTest : KoinComponent {
|
||||||
val suffix2 = getRandomBase64(23)
|
val suffix2 = getRandomBase64(23)
|
||||||
val apk2 = getRandomByteArray(23 * 1024 * 1024)
|
val apk2 = getRandomByteArray(23 * 1024 * 1024)
|
||||||
|
|
||||||
backend.save(LegacyAppBackupFile.Blob(token, "${packageInfo2.packageName}$suffix2.apk"))
|
storagePlugin.getOutputStream(token, "${packageInfo2.packageName}$suffix2.apk")
|
||||||
.writeAndClose(apk2)
|
.writeAndClose(apk2)
|
||||||
|
|
||||||
// assert that read APK bytes match what was written
|
// assert that read APK bytes match what was written
|
||||||
|
@ -180,27 +199,42 @@ class PluginTest : KoinComponent {
|
||||||
val name1 = getRandomBase64()
|
val name1 = getRandomBase64()
|
||||||
val name2 = getRandomBase64()
|
val name2 = getRandomBase64()
|
||||||
|
|
||||||
|
// no data available initially
|
||||||
|
assertFalse(storagePlugin.hasData(token, name1))
|
||||||
|
assertFalse(storagePlugin.hasData(token, name2))
|
||||||
|
|
||||||
// write full backup data
|
// write full backup data
|
||||||
val data = getRandomByteArray(5 * 1024 * 1024)
|
val data = getRandomByteArray(5 * 1024 * 1024)
|
||||||
backend.save(LegacyAppBackupFile.Blob(token, name1)).writeAndClose(data)
|
storagePlugin.getOutputStream(token, name1).writeAndClose(data)
|
||||||
|
|
||||||
|
// data is available now, but only this token
|
||||||
|
assertTrue(storagePlugin.hasData(token, name1))
|
||||||
|
assertFalse(storagePlugin.hasData(token + 1, name1))
|
||||||
|
|
||||||
// restore data matches backed up data
|
// restore data matches backed up data
|
||||||
assertReadEquals(data, backend.load(LegacyAppBackupFile.Blob(token, name1)))
|
assertReadEquals(data, storagePlugin.getInputStream(token, name1))
|
||||||
|
|
||||||
// write and check data for second package
|
// write and check data for second package
|
||||||
val data2 = getRandomByteArray(5 * 1024 * 1024)
|
val data2 = getRandomByteArray(5 * 1024 * 1024)
|
||||||
backend.save(LegacyAppBackupFile.Blob(token, name2)).writeAndClose(data2)
|
storagePlugin.getOutputStream(token, name2).writeAndClose(data2)
|
||||||
assertReadEquals(data2, backend.load(LegacyAppBackupFile.Blob(token, name2)))
|
assertTrue(storagePlugin.hasData(token, name2))
|
||||||
|
assertReadEquals(data2, storagePlugin.getInputStream(token, name2))
|
||||||
|
|
||||||
// remove data of first package again and ensure that no more data is found
|
// remove data of first package again and ensure that no more data is found
|
||||||
backend.remove(LegacyAppBackupFile.Blob(token, name1))
|
storagePlugin.removeData(token, name1)
|
||||||
|
assertFalse(storagePlugin.hasData(token, name1))
|
||||||
|
|
||||||
|
// second package is still there
|
||||||
|
assertTrue(storagePlugin.hasData(token, name2))
|
||||||
|
|
||||||
// ensure that it gets deleted as well
|
// ensure that it gets deleted as well
|
||||||
backend.remove(LegacyAppBackupFile.Blob(token, name2))
|
storagePlugin.removeData(token, name2)
|
||||||
|
assertFalse(storagePlugin.hasData(token, name2))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initStorage(token: Long) = runBlocking {
|
private fun initStorage(token: Long) = runBlocking {
|
||||||
every { mockedSettingsManager.token } returns token
|
every { mockedSettingsManager.getToken() } returns token
|
||||||
|
storagePlugin.initializeDevice()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend.saf
|
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.MediumTest
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
|
||||||
import org.calyxos.seedvault.core.backends.BackendTest
|
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
import org.koin.core.component.inject
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@MediumTest
|
|
||||||
class SafBackendTest : BackendTest(), KoinComponent {
|
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
private val settingsManager by inject<SettingsManager>()
|
|
||||||
private val safProperties = settingsManager.getSafProperties() ?: error("No SAF storage")
|
|
||||||
override val backend: Backend = SafBackend(context, safProperties, ".SeedvaultTest")
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `test write list read rename delete`(): Unit = runBlocking {
|
|
||||||
testWriteListReadRenameDelete()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `test remove create write file`(): Unit = runBlocking {
|
|
||||||
testRemoveCreateWriteFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `test free space and create app blob without root folder`(): Unit = runBlocking {
|
|
||||||
testTestFreeSpaceAndCreateBlob()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -9,6 +9,7 @@ import android.content.pm.PackageInfo
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import androidx.test.uiautomator.Until
|
import androidx.test.uiautomator.Until
|
||||||
import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept
|
import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept
|
||||||
|
import com.stevesoltys.seedvault.e2e.io.InputStreamIntercept
|
||||||
import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen
|
import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen
|
||||||
import com.stevesoltys.seedvault.transport.backup.FullBackup
|
import com.stevesoltys.seedvault.transport.backup.FullBackup
|
||||||
import com.stevesoltys.seedvault.transport.backup.InputFactory
|
import com.stevesoltys.seedvault.transport.backup.InputFactory
|
||||||
|
@ -20,12 +21,9 @@ import io.mockk.every
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
import java.security.DigestInputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.test.fail
|
|
||||||
|
|
||||||
internal interface LargeBackupTestBase : LargeTestBase {
|
internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
|
|
||||||
|
@ -76,6 +74,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
full = mutableMapOf(),
|
full = mutableMapOf(),
|
||||||
kv = mutableMapOf(),
|
kv = mutableMapOf(),
|
||||||
userApps = packageService.userApps,
|
userApps = packageService.userApps,
|
||||||
|
userNotAllowedApps = packageService.userNotAllowedApps
|
||||||
)
|
)
|
||||||
|
|
||||||
val completed = spyOnBackup(backupResult)
|
val completed = spyOnBackup(backupResult)
|
||||||
|
@ -112,7 +111,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
var data = mutableMapOf<String, ByteArray>()
|
var data = mutableMapOf<String, ByteArray>()
|
||||||
|
|
||||||
coEvery {
|
coEvery {
|
||||||
spyKVBackup.performBackup(any(), any(), any())
|
spyKVBackup.performBackup(any(), any(), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
packageName = firstArg<PackageInfo>().packageName
|
packageName = firstArg<PackageInfo>().packageName
|
||||||
callOriginal()
|
callOriginal()
|
||||||
|
@ -155,11 +154,10 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
|
|
||||||
private fun spyOnFullBackupData(backupResult: SeedvaultLargeTestResult) {
|
private fun spyOnFullBackupData(backupResult: SeedvaultLargeTestResult) {
|
||||||
var packageName: String? = null
|
var packageName: String? = null
|
||||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
var dataIntercept = ByteArrayOutputStream()
|
||||||
var digestInputStream: DigestInputStream? = null
|
|
||||||
|
|
||||||
coEvery {
|
coEvery {
|
||||||
spyFullBackup.performFullBackup(any(), any(), any())
|
spyFullBackup.performFullBackup(any(), any(), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
packageName = firstArg<PackageInfo>().packageName
|
packageName = firstArg<PackageInfo>().packageName
|
||||||
callOriginal()
|
callOriginal()
|
||||||
|
@ -168,19 +166,20 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
every {
|
every {
|
||||||
spyInputFactory.getInputStream(any())
|
spyInputFactory.getInputStream(any())
|
||||||
} answers {
|
} answers {
|
||||||
digestInputStream = DigestInputStream(callOriginal(), messageDigest)
|
InputStreamIntercept(
|
||||||
digestInputStream!!
|
inputStream = callOriginal(),
|
||||||
|
intercept = dataIntercept
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
coEvery {
|
every {
|
||||||
spyFullBackup.finishBackup()
|
spyFullBackup.finishBackup()
|
||||||
} answers {
|
} answers {
|
||||||
val result = callOriginal()
|
val result = callOriginal()
|
||||||
val digest = digestInputStream?.messageDigest ?: fail("No digestInputStream")
|
backupResult.full[packageName!!] = dataIntercept.toByteArray().sha256()
|
||||||
backupResult.full[packageName!!] = digest.digest().toHexString()
|
|
||||||
|
|
||||||
packageName = null
|
packageName = null
|
||||||
digest.reset()
|
dataIntercept = ByteArrayOutputStream()
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -191,18 +190,14 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
clearMocks(spyBackupNotificationManager)
|
clearMocks(spyBackupNotificationManager)
|
||||||
|
|
||||||
every {
|
every {
|
||||||
spyBackupNotificationManager.onBackupSuccess(any(), any(), any())
|
spyBackupNotificationManager.onBackupFinished(any(), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
|
val success = firstArg<Boolean>()
|
||||||
|
assert(success) { "Backup failed." }
|
||||||
|
|
||||||
callOriginal()
|
callOriginal()
|
||||||
completed.set(true)
|
completed.set(true)
|
||||||
}
|
}
|
||||||
every {
|
|
||||||
spyBackupNotificationManager.onBackupError()
|
|
||||||
} answers {
|
|
||||||
callOriginal()
|
|
||||||
completed.set(true)
|
|
||||||
fail("Backup failed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return completed
|
return completed
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,13 +8,12 @@ package com.stevesoltys.seedvault.e2e
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import com.stevesoltys.seedvault.e2e.io.BackupDataOutputIntercept
|
import com.stevesoltys.seedvault.e2e.io.BackupDataOutputIntercept
|
||||||
|
import com.stevesoltys.seedvault.e2e.io.OutputStreamIntercept
|
||||||
import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen
|
import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen
|
||||||
import com.stevesoltys.seedvault.e2e.screen.impl.RestoreScreen
|
import com.stevesoltys.seedvault.e2e.screen.impl.RestoreScreen
|
||||||
import com.stevesoltys.seedvault.transport.restore.FullRestore
|
import com.stevesoltys.seedvault.transport.restore.FullRestore
|
||||||
import com.stevesoltys.seedvault.transport.restore.KVRestore
|
import com.stevesoltys.seedvault.transport.restore.KVRestore
|
||||||
import com.stevesoltys.seedvault.transport.restore.OutputFactory
|
import com.stevesoltys.seedvault.transport.restore.OutputFactory
|
||||||
import io.mockk.Call
|
|
||||||
import io.mockk.MockKAnswerScope
|
|
||||||
import io.mockk.clearMocks
|
import io.mockk.clearMocks
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
@ -23,11 +22,8 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
import java.security.DigestOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.security.MessageDigest
|
|
||||||
import kotlin.test.fail
|
|
||||||
|
|
||||||
internal interface LargeRestoreTestBase : LargeTestBase {
|
internal interface LargeRestoreTestBase : LargeTestBase {
|
||||||
|
|
||||||
|
@ -67,6 +63,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
||||||
full = mutableMapOf(),
|
full = mutableMapOf(),
|
||||||
kv = mutableMapOf(),
|
kv = mutableMapOf(),
|
||||||
userApps = emptyList(), // will update everything below this after restore
|
userApps = emptyList(), // will update everything below this after restore
|
||||||
|
userNotAllowedApps = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
spyOnRestoreData(result)
|
spyOnRestoreData(result)
|
||||||
|
@ -100,6 +97,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
||||||
|
|
||||||
return result.copy(
|
return result.copy(
|
||||||
userApps = packageService.userApps,
|
userApps = packageService.userApps,
|
||||||
|
userNotAllowedApps = packageService.userNotAllowedApps
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,26 +163,14 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
||||||
|
|
||||||
clearMocks(spyKVRestore)
|
clearMocks(spyKVRestore)
|
||||||
|
|
||||||
fun initializeStateBlock(
|
coEvery {
|
||||||
packageInfoIndex: Int
|
spyKVRestore.initializeState(any(), any(), any(), any(), any())
|
||||||
): MockKAnswerScope<Unit, Unit>.(Call) -> Unit = {
|
} answers {
|
||||||
packageName = arg<PackageInfo>(packageInfoIndex).packageName
|
packageName = arg<PackageInfo>(3).packageName
|
||||||
restoreResult.kv[packageName!!] = mutableMapOf()
|
restoreResult.kv[packageName!!] = mutableMapOf()
|
||||||
callOriginal()
|
callOriginal()
|
||||||
}
|
}
|
||||||
|
|
||||||
coEvery {
|
|
||||||
spyKVRestore.initializeState(any(), any(), any(), any())
|
|
||||||
} answers initializeStateBlock(1)
|
|
||||||
|
|
||||||
coEvery {
|
|
||||||
spyKVRestore.initializeStateV1(any(), any(), any(), any())
|
|
||||||
} answers initializeStateBlock(2)
|
|
||||||
|
|
||||||
coEvery {
|
|
||||||
spyKVRestore.initializeStateV0(any(), any())
|
|
||||||
} answers initializeStateBlock(1)
|
|
||||||
|
|
||||||
every {
|
every {
|
||||||
spyOutputFactory.getBackupDataOutput(any())
|
spyOutputFactory.getBackupDataOutput(any())
|
||||||
} answers {
|
} answers {
|
||||||
|
@ -198,61 +184,47 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
||||||
|
|
||||||
private fun spyOnFullRestoreData(restoreResult: SeedvaultLargeTestResult) {
|
private fun spyOnFullRestoreData(restoreResult: SeedvaultLargeTestResult) {
|
||||||
var packageName: String? = null
|
var packageName: String? = null
|
||||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
var dataIntercept = ByteArrayOutputStream()
|
||||||
var digestOutputStream: DigestOutputStream? = null
|
|
||||||
|
|
||||||
clearMocks(spyFullRestore)
|
clearMocks(spyFullRestore)
|
||||||
|
|
||||||
fun initializeStateBlock(
|
coEvery {
|
||||||
packageInfoIndex: Int
|
spyFullRestore.initializeState(any(), any(), any(), any())
|
||||||
): MockKAnswerScope<Unit, Unit>.(Call) -> Unit = {
|
} answers {
|
||||||
packageName?.let {
|
packageName?.let {
|
||||||
// sometimes finishRestore() doesn't get called, so get data from last package here
|
restoreResult.full[it] = dataIntercept.toByteArray().sha256()
|
||||||
digestOutputStream?.messageDigest?.let { digest ->
|
|
||||||
restoreResult.full[packageName!!] = digest.digest().toHexString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
packageName = arg<PackageInfo>(packageInfoIndex).packageName
|
packageName = arg<PackageInfo>(3).packageName
|
||||||
|
dataIntercept = ByteArrayOutputStream()
|
||||||
|
|
||||||
callOriginal()
|
callOriginal()
|
||||||
}
|
}
|
||||||
|
|
||||||
coEvery {
|
|
||||||
spyFullRestore.initializeState(any(), any(), any())
|
|
||||||
} answers initializeStateBlock(1)
|
|
||||||
|
|
||||||
coEvery {
|
|
||||||
spyFullRestore.initializeStateV1(any(), any(), any())
|
|
||||||
} answers initializeStateBlock(2)
|
|
||||||
|
|
||||||
coEvery {
|
|
||||||
spyFullRestore.initializeStateV0(any(), any())
|
|
||||||
} answers initializeStateBlock(1)
|
|
||||||
|
|
||||||
every {
|
every {
|
||||||
spyOutputFactory.getOutputStream(any())
|
spyOutputFactory.getOutputStream(any())
|
||||||
} answers {
|
} answers {
|
||||||
digestOutputStream = DigestOutputStream(callOriginal(), messageDigest)
|
OutputStreamIntercept(
|
||||||
digestOutputStream!!
|
outputStream = callOriginal(),
|
||||||
|
intercept = dataIntercept
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
every {
|
every {
|
||||||
spyFullRestore.abortFullRestore()
|
spyFullRestore.abortFullRestore()
|
||||||
} answers {
|
} answers {
|
||||||
packageName = null
|
packageName = null
|
||||||
digestOutputStream?.messageDigest?.reset()
|
dataIntercept = ByteArrayOutputStream()
|
||||||
callOriginal()
|
callOriginal()
|
||||||
}
|
}
|
||||||
|
|
||||||
every {
|
every {
|
||||||
spyFullRestore.finishRestore()
|
spyFullRestore.finishRestore()
|
||||||
} answers {
|
} answers {
|
||||||
val digest = digestOutputStream?.messageDigest ?: fail("No digestOutputStream")
|
restoreResult.full[packageName!!] = dataIntercept.toByteArray().sha256()
|
||||||
restoreResult.full[packageName!!] = digest.digest().toHexString()
|
|
||||||
|
|
||||||
packageName = null
|
packageName = null
|
||||||
digest.reset()
|
dataIntercept = ByteArrayOutputStream()
|
||||||
callOriginal()
|
callOriginal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,14 +49,14 @@ internal interface LargeTestBase : KoinComponent {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TEST_STORAGE_FOLDER = "seedvault_test"
|
private const val TEST_STORAGE_FOLDER = "seedvault_test"
|
||||||
private const val TEST_RESULT_FOLDER = "seedvault_test_results"
|
private const val TEST_VIDEO_FOLDER = "seedvault_test_results"
|
||||||
}
|
}
|
||||||
|
|
||||||
val externalStorageDir: String get() = Environment.getExternalStorageDirectory().absolutePath
|
val externalStorageDir: String get() = Environment.getExternalStorageDirectory().absolutePath
|
||||||
|
|
||||||
val testStoragePath get() = "$externalStorageDir/$TEST_STORAGE_FOLDER"
|
val testStoragePath get() = "$externalStorageDir/$TEST_STORAGE_FOLDER"
|
||||||
|
|
||||||
val testResultPath get() = "$externalStorageDir/$TEST_RESULT_FOLDER"
|
val testVideoPath get() = "$externalStorageDir/$TEST_VIDEO_FOLDER"
|
||||||
|
|
||||||
val targetContext: Context
|
val targetContext: Context
|
||||||
get() = InstrumentationRegistry.getInstrumentation().targetContext
|
get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
@ -85,6 +85,7 @@ internal interface LargeTestBase : KoinComponent {
|
||||||
|
|
||||||
fun resetApplicationState() {
|
fun resetApplicationState() {
|
||||||
backupManager.setAutoRestore(false)
|
backupManager.setAutoRestore(false)
|
||||||
|
settingsManager.setNewToken(null)
|
||||||
|
|
||||||
val sharedPreferences = permitDiskReads {
|
val sharedPreferences = permitDiskReads {
|
||||||
PreferenceManager.getDefaultSharedPreferences(targetContext)
|
PreferenceManager.getDefaultSharedPreferences(targetContext)
|
||||||
|
@ -112,9 +113,11 @@ internal interface LargeTestBase : KoinComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun testResultFilename(testName: String): String {
|
fun testResultFilename(testName: String): String {
|
||||||
|
val arguments = InstrumentationRegistry.getArguments()
|
||||||
|
val d2d = if (arguments.getString("d2d_backup_test") == "true") "d2d" else ""
|
||||||
val simpleDateFormat = SimpleDateFormat("yyyyMMdd_hhmmss")
|
val simpleDateFormat = SimpleDateFormat("yyyyMMdd_hhmmss")
|
||||||
val timeStamp = simpleDateFormat.format(Calendar.getInstance().time)
|
val timeStamp = simpleDateFormat.format(Calendar.getInstance().time)
|
||||||
return "${timeStamp}_${testName.replace(" ", "_")}"
|
return "${timeStamp}_${d2d}_${testName.replace(" ", "_")}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
@ -123,7 +126,7 @@ internal interface LargeTestBase : KoinComponent {
|
||||||
keepRecordingScreen: AtomicBoolean,
|
keepRecordingScreen: AtomicBoolean,
|
||||||
testName: String,
|
testName: String,
|
||||||
) {
|
) {
|
||||||
val folder = testResultPath
|
val folder = testVideoPath
|
||||||
runCommand("mkdir -p $folder")
|
runCommand("mkdir -p $folder")
|
||||||
|
|
||||||
val fileName = testResultFilename(testName)
|
val fileName = testResultFilename(testName)
|
||||||
|
@ -149,7 +152,7 @@ internal interface LargeTestBase : KoinComponent {
|
||||||
|
|
||||||
// write logcat to file
|
// write logcat to file
|
||||||
val fileName = testResultFilename(testName)
|
val fileName = testResultFilename(testName)
|
||||||
runCommand("logcat -d -f $testResultPath/$fileName.log")
|
runCommand("logcat -d -f $testVideoPath/$fileName.log")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun uninstallPackages(packages: Collection<PackageInfo>) {
|
fun uninstallPackages(packages: Collection<PackageInfo>) {
|
||||||
|
@ -162,7 +165,7 @@ internal interface LargeTestBase : KoinComponent {
|
||||||
|
|
||||||
fun clearTestBackups() {
|
fun clearTestBackups() {
|
||||||
File(testStoragePath).deleteRecursively()
|
File(testStoragePath).deleteRecursively()
|
||||||
File(testResultPath).deleteRecursively()
|
File(testVideoPath).deleteRecursively()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun changeBackupLocation(
|
fun changeBackupLocation(
|
||||||
|
@ -225,7 +228,6 @@ internal interface LargeTestBase : KoinComponent {
|
||||||
|
|
||||||
fun confirmCode() {
|
fun confirmCode() {
|
||||||
RecoveryCodeScreen {
|
RecoveryCodeScreen {
|
||||||
startNewBackupButton.click()
|
|
||||||
confirmCodeButton.click()
|
confirmCodeButton.click()
|
||||||
|
|
||||||
verifyCodeButton.scrollTo().click()
|
verifyCodeButton.scrollTo().click()
|
||||||
|
|
|
@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.e2e
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
@ -45,11 +46,23 @@ internal abstract class SeedvaultLargeTest :
|
||||||
clearTestBackups()
|
clearTestBackups()
|
||||||
|
|
||||||
runCommand("bmgr enable true")
|
runCommand("bmgr enable true")
|
||||||
|
sleep(60_000)
|
||||||
runCommand("bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport")
|
runCommand("bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport")
|
||||||
sleep(5000)
|
sleep(60_000)
|
||||||
|
|
||||||
startRecordingTest(keepRecordingScreen, name.methodName)
|
startRecordingTest(keepRecordingScreen, name.methodName)
|
||||||
restoreBaselineBackup()
|
restoreBaselineBackup()
|
||||||
|
|
||||||
|
val arguments = InstrumentationRegistry.getArguments()
|
||||||
|
|
||||||
|
if (arguments.getString("d2d_backup_test") == "true") {
|
||||||
|
println("Enabling D2D backups for test")
|
||||||
|
settingsManager.setD2dBackupsEnabled(true)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
println("Disabling D2D backups for test")
|
||||||
|
settingsManager.setD2dBackupsEnabled(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|
|
@ -24,6 +24,7 @@ internal data class SeedvaultLargeTestResult(
|
||||||
val full: MutableMap<String, String>,
|
val full: MutableMap<String, String>,
|
||||||
val kv: MutableMap<String, MutableMap<String, String>>,
|
val kv: MutableMap<String, MutableMap<String, String>>,
|
||||||
val userApps: List<PackageInfo>,
|
val userApps: List<PackageInfo>,
|
||||||
|
val userNotAllowedApps: List<PackageInfo>,
|
||||||
) {
|
) {
|
||||||
fun allUserApps() = userApps
|
fun allUserApps() = userApps + userNotAllowedApps
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,14 +5,11 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.e2e.impl
|
package com.stevesoltys.seedvault.e2e.impl
|
||||||
|
|
||||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.test.filters.LargeTest
|
import androidx.test.filters.LargeTest
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTest
|
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTest
|
||||||
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTestResult
|
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTestResult
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState
|
import com.stevesoltys.seedvault.metadata.PackageState
|
||||||
import com.stevesoltys.seedvault.transport.backup.isStopped
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@LargeTest
|
@LargeTest
|
||||||
|
@ -26,15 +23,12 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
|
||||||
confirmCode()
|
confirmCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsManager.getSafProperties() == null) {
|
if (settingsManager.getSafStorage() == null) {
|
||||||
chooseStorageLocation()
|
chooseStorageLocation()
|
||||||
} else {
|
} else {
|
||||||
changeBackupLocation()
|
changeBackupLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
launchStoppedApps()
|
|
||||||
launchBackupActivity()
|
|
||||||
|
|
||||||
val backupResult = performBackup()
|
val backupResult = performBackup()
|
||||||
assertValidBackupMetadata(backupResult)
|
assertValidBackupMetadata(backupResult)
|
||||||
|
|
||||||
|
@ -64,28 +58,6 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun launchStoppedApps() {
|
|
||||||
val packageManager = targetContext.packageManager
|
|
||||||
val notBackedUp = packageService.notBackedUpPackages
|
|
||||||
notBackedUp.forEach { packageInfo ->
|
|
||||||
val i = packageManager.getLaunchIntentForPackage(packageInfo.packageName)?.apply {
|
|
||||||
addFlags(FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
Log.i("TEST", "Launching $i")
|
|
||||||
try {
|
|
||||||
targetContext.startActivity(i)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("TEST", "Could not launch activity for ${packageInfo.packageName}", e)
|
|
||||||
}
|
|
||||||
waitUntilIdle()
|
|
||||||
}
|
|
||||||
waitUntilIdle()
|
|
||||||
notBackedUp.forEach { packageInfo ->
|
|
||||||
val pi = packageManager.getPackageInfo(packageInfo.packageName, 0)
|
|
||||||
Log.e("TEST", "${packageInfo.packageName} isStopped: ${pi.isStopped()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun assertValidResults(
|
private fun assertValidResults(
|
||||||
backup: SeedvaultLargeTestResult,
|
backup: SeedvaultLargeTestResult,
|
||||||
restore: SeedvaultLargeTestResult,
|
restore: SeedvaultLargeTestResult,
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.e2e.io
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class InputStreamIntercept(
|
||||||
|
private val inputStream: InputStream,
|
||||||
|
private val intercept: ByteArrayOutputStream
|
||||||
|
) : InputStream() {
|
||||||
|
|
||||||
|
override fun read(): Int {
|
||||||
|
val byte = inputStream.read()
|
||||||
|
if (byte != -1) {
|
||||||
|
intercept.write(byte)
|
||||||
|
}
|
||||||
|
return byte
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||||
|
val bytesRead = inputStream.read(buffer, offset, length)
|
||||||
|
if (bytesRead != -1) {
|
||||||
|
intercept.write(buffer, offset, bytesRead)
|
||||||
|
}
|
||||||
|
return bytesRead
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.e2e.io
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
class OutputStreamIntercept(
|
||||||
|
private val outputStream: OutputStream,
|
||||||
|
private val intercept: ByteArrayOutputStream
|
||||||
|
) : OutputStream() {
|
||||||
|
|
||||||
|
override fun write(byte: Int) {
|
||||||
|
intercept.write(byte)
|
||||||
|
outputStream.write(byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun write(buffer: ByteArray, offset: Int, length: Int) {
|
||||||
|
intercept.write(buffer, offset, length)
|
||||||
|
outputStream.write(buffer, offset, length)
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,7 +24,7 @@ object BackupScreen : UiDeviceScreen<BackupScreen>() {
|
||||||
|
|
||||||
val internalStorageButton = findObject { textContains(Build.MODEL) }
|
val internalStorageButton = findObject { textContains(Build.MODEL) }
|
||||||
|
|
||||||
val useAnywayButton = findObject { text("Use anyway") }
|
val useAnywayButton = findObject { text("USE ANYWAY") }
|
||||||
|
|
||||||
val initializingText: BySelector = By.textContains("Initializing backup location")
|
val initializingText: BySelector = By.textContains("Initializing backup location")
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,6 @@ import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
|
||||||
|
|
||||||
object RecoveryCodeScreen : UiDeviceScreen<RecoveryCodeScreen>() {
|
object RecoveryCodeScreen : UiDeviceScreen<RecoveryCodeScreen>() {
|
||||||
|
|
||||||
val startNewBackupButton = findObject { text("Start new") }
|
|
||||||
|
|
||||||
val confirmCodeButton = findObject { text("Confirm code") }
|
val confirmCodeButton = findObject { text("Confirm code") }
|
||||||
|
|
||||||
val verifyCodeButton = findObject { text("Verify") }
|
val verifyCodeButton = findObject { text("Verify") }
|
||||||
|
|
|
@ -9,9 +9,7 @@ import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
|
||||||
|
|
||||||
object RestoreScreen : UiDeviceScreen<RestoreScreen>() {
|
object RestoreScreen : UiDeviceScreen<RestoreScreen>() {
|
||||||
|
|
||||||
val backupListItem = findObject {
|
val backupListItem = findObject { textContains("Last backup") }
|
||||||
textContains("Android SDK") // device name of test backups
|
|
||||||
}
|
|
||||||
|
|
||||||
val appsSelectedButton = findObject { text("Restore backup") }
|
val appsSelectedButton = findObject { text("Restore backup") }
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,226 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.database.ContentObserver
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract.EXTRA_LOADING
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.stevesoltys.seedvault.assertReadEquals
|
||||||
|
import com.stevesoltys.seedvault.coAssertThrows
|
||||||
|
import com.stevesoltys.seedvault.getRandomBase64
|
||||||
|
import com.stevesoltys.seedvault.getRandomByteArray
|
||||||
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.writeAndClose
|
||||||
|
import io.mockk.Runs
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
|
class DocumentsStorageTest : KoinComponent {
|
||||||
|
|
||||||
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val settingsManager by inject<SettingsManager>()
|
||||||
|
private val storage = DocumentsStorage(
|
||||||
|
appContext = context,
|
||||||
|
settingsManager = settingsManager,
|
||||||
|
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val filename = getRandomBase64()
|
||||||
|
private lateinit var file: DocumentFile
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() = runBlocking {
|
||||||
|
assertNotNull("Select a storage location in the app first!", storage.rootBackupDir)
|
||||||
|
file = storage.rootBackupDir?.createOrGetFile(context, filename)
|
||||||
|
?: error("Could not create test file")
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testWritingAndReadingFile() {
|
||||||
|
// write to output stream
|
||||||
|
val outputStream = storage.getOutputStream(file)
|
||||||
|
val content = ByteArray(1337).apply { Random.nextBytes(this) }
|
||||||
|
outputStream.write(content)
|
||||||
|
outputStream.flush()
|
||||||
|
outputStream.close()
|
||||||
|
|
||||||
|
// read written data from input stream
|
||||||
|
val inputStream = storage.getInputStream(file)
|
||||||
|
val readContent = inputStream.readBytes()
|
||||||
|
inputStream.close()
|
||||||
|
assertArrayEquals(content, readContent)
|
||||||
|
|
||||||
|
// write smaller content to same file
|
||||||
|
val outputStream2 = storage.getOutputStream(file)
|
||||||
|
val content2 = ByteArray(42).apply { Random.nextBytes(this) }
|
||||||
|
outputStream2.write(content2)
|
||||||
|
outputStream2.flush()
|
||||||
|
outputStream2.close()
|
||||||
|
|
||||||
|
// read written data from input stream
|
||||||
|
val inputStream2 = storage.getInputStream(file)
|
||||||
|
val readContent2 = inputStream2.readBytes()
|
||||||
|
inputStream2.close()
|
||||||
|
assertArrayEquals(content2, readContent2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFindFile() = runBlocking(Dispatchers.IO) {
|
||||||
|
val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!)
|
||||||
|
assertNotNull(foundFile)
|
||||||
|
assertEquals(filename, foundFile!!.name)
|
||||||
|
assertEquals(storage.rootBackupDir!!.uri, foundFile.parentFile?.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateFile() {
|
||||||
|
// create test file
|
||||||
|
val dir = storage.rootBackupDir!!
|
||||||
|
val createdFile = dir.createFile("text", getRandomBase64())
|
||||||
|
assertNotNull(createdFile)
|
||||||
|
assertNotNull(createdFile!!.name)
|
||||||
|
|
||||||
|
// write some data into it
|
||||||
|
val data = getRandomByteArray()
|
||||||
|
context.contentResolver.openOutputStream(createdFile.uri)!!.writeAndClose(data)
|
||||||
|
|
||||||
|
// data should still be there
|
||||||
|
assertReadEquals(data, context.contentResolver.openInputStream(createdFile.uri))
|
||||||
|
|
||||||
|
// delete again
|
||||||
|
createdFile.delete()
|
||||||
|
assertFalse(createdFile.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTwoFiles() = runBlocking {
|
||||||
|
val mimeType = "application/octet-stream"
|
||||||
|
val dir = storage.rootBackupDir!!
|
||||||
|
|
||||||
|
// create test file
|
||||||
|
val name1 = getRandomBase64(Random.nextInt(1, 10))
|
||||||
|
val file1 = requireNotNull(dir.createFile(mimeType, name1))
|
||||||
|
assertTrue(file1.exists())
|
||||||
|
assertEquals(name1, file1.name)
|
||||||
|
assertEquals(0L, file1.length())
|
||||||
|
|
||||||
|
assertReadEquals(getRandomByteArray(0), context.contentResolver.openInputStream(file1.uri))
|
||||||
|
|
||||||
|
// write some data into it
|
||||||
|
val data1 = getRandomByteArray(5 * 1024 * 1024)
|
||||||
|
context.contentResolver.openOutputStream(file1.uri)!!.writeAndClose(data1)
|
||||||
|
assertEquals(data1.size.toLong(), file1.length())
|
||||||
|
|
||||||
|
// data should still be there
|
||||||
|
assertReadEquals(data1, context.contentResolver.openInputStream(file1.uri))
|
||||||
|
|
||||||
|
// create test file
|
||||||
|
val name2 = getRandomBase64(Random.nextInt(1, 10))
|
||||||
|
val file2 = requireNotNull(dir.createFile(mimeType, name2))
|
||||||
|
assertTrue(file2.exists())
|
||||||
|
assertEquals(name2, file2.name)
|
||||||
|
|
||||||
|
// write some data into it
|
||||||
|
val data2 = getRandomByteArray(12 * 1024 * 1024)
|
||||||
|
context.contentResolver.openOutputStream(file2.uri)!!.writeAndClose(data2)
|
||||||
|
assertEquals(data2.size.toLong(), file2.length())
|
||||||
|
|
||||||
|
// data should still be there
|
||||||
|
assertReadEquals(data2, context.contentResolver.openInputStream(file2.uri))
|
||||||
|
|
||||||
|
// delete files again
|
||||||
|
file1.delete()
|
||||||
|
file2.delete()
|
||||||
|
assertFalse(file1.exists())
|
||||||
|
assertFalse(file2.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetLoadedCursor() = runBlocking {
|
||||||
|
// empty cursor extras are like not loading, returns same cursor right away
|
||||||
|
val cursor1: Cursor = mockk()
|
||||||
|
every { cursor1.extras } returns Bundle()
|
||||||
|
assertEquals(cursor1, getLoadedCursor { cursor1 })
|
||||||
|
|
||||||
|
// explicitly not loading, returns same cursor right away
|
||||||
|
val cursor2: Cursor = mockk()
|
||||||
|
every { cursor2.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, false) }
|
||||||
|
assertEquals(cursor2, getLoadedCursor { cursor2 })
|
||||||
|
|
||||||
|
// loading cursor registers content observer, times out and closes cursor
|
||||||
|
val cursor3: Cursor = mockk()
|
||||||
|
every { cursor3.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
|
||||||
|
every { cursor3.registerContentObserver(any()) } just Runs
|
||||||
|
every { cursor3.close() } just Runs
|
||||||
|
coAssertThrows(TimeoutCancellationException::class.java) {
|
||||||
|
getLoadedCursor(1000) { cursor3 }
|
||||||
|
}
|
||||||
|
verify { cursor3.registerContentObserver(any()) }
|
||||||
|
verify { cursor3.close() } // ensure that cursor gets closed
|
||||||
|
|
||||||
|
// loading cursor registers content observer, but re-query fails
|
||||||
|
val cursor4: Cursor = mockk()
|
||||||
|
val observer4 = slot<ContentObserver>()
|
||||||
|
val query: () -> Cursor? = { if (observer4.isCaptured) null else cursor4 }
|
||||||
|
every { cursor4.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
|
||||||
|
every { cursor4.registerContentObserver(capture(observer4)) } answers {
|
||||||
|
observer4.captured.onChange(false, Uri.parse("foo://bar"))
|
||||||
|
}
|
||||||
|
every { cursor4.close() } just Runs
|
||||||
|
coAssertThrows(IOException::class.java) {
|
||||||
|
getLoadedCursor(10_000, query)
|
||||||
|
}
|
||||||
|
assertTrue(observer4.isCaptured)
|
||||||
|
verify { cursor4.close() } // ensure that cursor gets closed
|
||||||
|
|
||||||
|
// loading cursor registers content observer, re-queries and returns new result
|
||||||
|
val cursor5: Cursor = mockk()
|
||||||
|
val result5: Cursor = mockk()
|
||||||
|
val observer5 = slot<ContentObserver>()
|
||||||
|
val query5: () -> Cursor? = { if (observer5.isCaptured) result5 else cursor5 }
|
||||||
|
every { cursor5.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
|
||||||
|
every { cursor5.registerContentObserver(capture(observer5)) } answers {
|
||||||
|
observer5.captured.onChange(false, null)
|
||||||
|
}
|
||||||
|
every { cursor5.close() } just Runs
|
||||||
|
assertEquals(result5, getLoadedCursor(10_000, query5))
|
||||||
|
assertTrue(observer5.isCaptured)
|
||||||
|
verify { cursor5.close() } // ensure that initial cursor got closed
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,90 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.transport.backup
|
|
||||||
|
|
||||||
import android.app.backup.BackupDataInput
|
|
||||||
import android.app.backup.BackupTransport.FLAG_NON_INCREMENTAL
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.MediumTest
|
|
||||||
import com.stevesoltys.seedvault.repo.BackupData
|
|
||||||
import com.stevesoltys.seedvault.repo.BackupReceiver
|
|
||||||
import io.mockk.CapturingSlot
|
|
||||||
import io.mockk.Runs
|
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.just
|
|
||||||
import io.mockk.mockk
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.junit.Assert.assertArrayEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
import org.koin.core.component.inject
|
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlin.test.assertEquals
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@MediumTest
|
|
||||||
class KvBackupInstrumentationTest : KoinComponent {
|
|
||||||
|
|
||||||
private val backupReceiver: BackupReceiver = mockk()
|
|
||||||
private val inputFactory: InputFactory = mockk()
|
|
||||||
private val dbManager: KvDbManager by inject()
|
|
||||||
|
|
||||||
private val backup = KVBackup(
|
|
||||||
backupReceiver = backupReceiver,
|
|
||||||
inputFactory = inputFactory,
|
|
||||||
dbManager = dbManager,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val data = mockk<ParcelFileDescriptor>()
|
|
||||||
private val dataInput = mockk<BackupDataInput>()
|
|
||||||
private val key = "foo.bar"
|
|
||||||
private val dataValue = Random.nextBytes(23)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `test non-incremental backup with existing DB`() {
|
|
||||||
val packageName = "com.example"
|
|
||||||
val backupData = BackupData(emptyList(), emptyMap())
|
|
||||||
|
|
||||||
// create existing db
|
|
||||||
dbManager.getDb(packageName).use { db ->
|
|
||||||
db.put("foo", "bar".toByteArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
val packageInfo = PackageInfo().apply {
|
|
||||||
this.packageName = packageName
|
|
||||||
}
|
|
||||||
|
|
||||||
every { inputFactory.getBackupDataInput(data) } returns dataInput
|
|
||||||
every { dataInput.readNextHeader() } returnsMany listOf(true, false)
|
|
||||||
every { dataInput.key } returns key
|
|
||||||
every { dataInput.dataSize } returns dataValue.size
|
|
||||||
val slot = CapturingSlot<ByteArray>()
|
|
||||||
every { dataInput.readEntityData(capture(slot), 0, dataValue.size) } answers {
|
|
||||||
dataValue.copyInto(slot.captured)
|
|
||||||
dataValue.size
|
|
||||||
}
|
|
||||||
every { data.close() } just Runs
|
|
||||||
|
|
||||||
backup.performBackup(packageInfo, data, FLAG_NON_INCREMENTAL)
|
|
||||||
|
|
||||||
coEvery { backupReceiver.readFromStream(any(), any()) } returns backupData
|
|
||||||
|
|
||||||
runBlocking {
|
|
||||||
assertEquals(backupData, backup.finishBackup())
|
|
||||||
}
|
|
||||||
|
|
||||||
dbManager.getDb(packageName).use { db ->
|
|
||||||
assertNull(db.get("foo")) // existing data foo is gone
|
|
||||||
assertArrayEquals(dataValue, db.get(key)) // new data got added
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -9,12 +9,12 @@ import android.content.pm.PackageInfo
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.settings.AppStatus
|
import com.stevesoltys.seedvault.settings.AppStatus
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
@ -30,9 +30,9 @@ class PackageServiceTest : KoinComponent {
|
||||||
|
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
|
|
||||||
private val backendManager: BackendManager by inject()
|
private val storagePluginManager: StoragePluginManager by inject()
|
||||||
|
|
||||||
private val backend: Backend get() = backendManager.backend
|
private val storagePlugin: StoragePlugin<*> get() = storagePluginManager.appPlugin
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testNotAllowedPackages() {
|
fun testNotAllowedPackages() {
|
||||||
|
@ -65,6 +65,6 @@ class PackageServiceTest : KoinComponent {
|
||||||
assertTrue(packageService.shouldIncludeAppInBackup(packageInfo.packageName))
|
assertTrue(packageService.shouldIncludeAppInBackup(packageInfo.packageName))
|
||||||
|
|
||||||
// Should not backup storage provider
|
// Should not backup storage provider
|
||||||
assertFalse(packageService.shouldIncludeAppInBackup(backend.providerPackageName!!))
|
assertFalse(packageService.shouldIncludeAppInBackup(storagePlugin.providerPackageName!!))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,128 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.worker
|
|
||||||
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.MediumTest
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import com.github.luben.zstd.ZstdOutputStream
|
|
||||||
import com.google.protobuf.ByteString
|
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
|
||||||
import com.stevesoltys.seedvault.metadata.BackupType
|
|
||||||
import com.stevesoltys.seedvault.proto.SnapshotKt.blob
|
|
||||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
|
||||||
import com.stevesoltys.seedvault.repo.BackupData
|
|
||||||
import com.stevesoltys.seedvault.repo.BackupReceiver
|
|
||||||
import com.stevesoltys.seedvault.repo.Loader
|
|
||||||
import com.stevesoltys.seedvault.repo.SnapshotCreatorFactory
|
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
|
||||||
import io.mockk.Runs
|
|
||||||
import io.mockk.coEvery
|
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.just
|
|
||||||
import io.mockk.mockk
|
|
||||||
import io.mockk.slot
|
|
||||||
import junit.framework.TestCase.assertTrue
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import org.junit.Assert.assertArrayEquals
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
import org.koin.core.component.inject
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@MediumTest
|
|
||||||
class IconManagerTest : KoinComponent {
|
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
private val packageService by inject<PackageService>()
|
|
||||||
private val backupReceiver = mockk<BackupReceiver>()
|
|
||||||
private val loader = mockk<Loader>()
|
|
||||||
private val appBackupManager = mockk<AppBackupManager>()
|
|
||||||
private val snapshotCreatorFactory by inject<SnapshotCreatorFactory>()
|
|
||||||
private val snapshotCreator = snapshotCreatorFactory.createSnapshotCreator()
|
|
||||||
|
|
||||||
private val iconManager = IconManager(
|
|
||||||
context = context,
|
|
||||||
packageService = packageService,
|
|
||||||
crypto = mockk(),
|
|
||||||
backupReceiver = backupReceiver,
|
|
||||||
loader = loader,
|
|
||||||
appBackupManager = appBackupManager,
|
|
||||||
)
|
|
||||||
|
|
||||||
init {
|
|
||||||
every { appBackupManager.snapshotCreator } returns snapshotCreator
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `test upload and then download`(): Unit = runBlocking {
|
|
||||||
// prepare output data
|
|
||||||
val output = slot<ByteArray>()
|
|
||||||
val chunkId = Random.nextBytes(32).toHexString()
|
|
||||||
val chunkList = listOf(chunkId)
|
|
||||||
val blobId = Random.nextBytes(32).toHexString()
|
|
||||||
val blob = blob { id = ByteString.fromHex(blobId) }
|
|
||||||
|
|
||||||
// upload icons and capture plaintext bytes
|
|
||||||
coEvery { backupReceiver.addBytes(any(), capture(output)) } just Runs
|
|
||||||
coEvery {
|
|
||||||
backupReceiver.finalize(any())
|
|
||||||
} returns BackupData(chunkList, mapOf(chunkId to blob))
|
|
||||||
iconManager.uploadIcons()
|
|
||||||
assertTrue(output.captured.isNotEmpty())
|
|
||||||
|
|
||||||
// @pm@ is needed
|
|
||||||
val pmPackageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
|
||||||
val backupData = BackupData(emptyList(), emptyMap())
|
|
||||||
snapshotCreator.onPackageBackedUp(pmPackageInfo, BackupType.KV, backupData)
|
|
||||||
|
|
||||||
// get snapshot and assert it has icon chunks
|
|
||||||
val snapshot = snapshotCreator.finalizeSnapshot()
|
|
||||||
assertTrue(snapshot.iconChunkIdsCount > 0)
|
|
||||||
|
|
||||||
// prepare data for downloading icons
|
|
||||||
val repoId = Random.nextBytes(32).toHexString()
|
|
||||||
val inputStream = ByteArrayInputStream(output.captured)
|
|
||||||
coEvery { loader.loadFile(AppBackupFileType.Blob(repoId, blobId)) } returns inputStream
|
|
||||||
|
|
||||||
// download icons and ensure we had an icon for at least one app
|
|
||||||
val iconSet = iconManager.downloadIcons(repoId, snapshot)
|
|
||||||
assertTrue(iconSet.isNotEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `test upload produces deterministic output`(): Unit = runBlocking {
|
|
||||||
val output1 = slot<ByteArray>()
|
|
||||||
val output2 = slot<ByteArray>()
|
|
||||||
|
|
||||||
coEvery { backupReceiver.addBytes(any(), capture(output1)) } just Runs
|
|
||||||
coEvery { backupReceiver.finalize(any()) } returns BackupData(emptyList(), emptyMap())
|
|
||||||
iconManager.uploadIcons()
|
|
||||||
assertTrue(output1.captured.isNotEmpty())
|
|
||||||
|
|
||||||
coEvery { backupReceiver.addBytes(any(), capture(output2)) } just Runs
|
|
||||||
coEvery { backupReceiver.finalize(any()) } returns BackupData(emptyList(), emptyMap())
|
|
||||||
iconManager.uploadIcons()
|
|
||||||
assertTrue(output2.captured.isNotEmpty())
|
|
||||||
|
|
||||||
assertArrayEquals(output1.captured, output2.captured)
|
|
||||||
|
|
||||||
// print compressed and uncompressed size
|
|
||||||
val size = output1.captured.size.toFloat() / 1024 / 1024
|
|
||||||
val outputStream = ByteArrayOutputStream()
|
|
||||||
ZstdOutputStream(outputStream).use { it.write(output1.captured) }
|
|
||||||
val compressedSize = outputStream.size().toFloat() / 1024 / 1024
|
|
||||||
println("Icon size: $size MB, compressed $compressedSize MB")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -7,8 +7,8 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="com.stevesoltys.seedvault"
|
package="com.stevesoltys.seedvault"
|
||||||
android:versionCode="35050000"
|
android:versionCode="34040010"
|
||||||
android:versionName="15-5.0">
|
android:versionName="14-4.1">
|
||||||
<!--
|
<!--
|
||||||
The version code is the targeted SDK_VERSION plus 6 digits for our own version code.
|
The version code is the targeted SDK_VERSION plus 6 digits for our own version code.
|
||||||
The version name is the targeted Android version followed by - and our own version name.
|
The version name is the targeted Android version followed by - and our own version name.
|
||||||
|
@ -101,9 +101,7 @@
|
||||||
<activity
|
<activity
|
||||||
android:name=".settings.SettingsActivity"
|
android:name=".settings.SettingsActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTask"
|
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS" />
|
||||||
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS"
|
|
||||||
android:windowSoftInputMode="adjustResize" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.storage.StorageActivity"
|
android:name=".ui.storage.StorageActivity"
|
||||||
|
@ -116,14 +114,12 @@
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.recoverycode.RecoveryCodeActivity"
|
android:name=".ui.recoverycode.RecoveryCodeActivity"
|
||||||
android:label="@string/recovery_code_title"
|
android:label="@string/recovery_code_title" />
|
||||||
android:windowSoftInputMode="adjustResize" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".restore.RestoreActivity"
|
android:name=".restore.RestoreActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/restore_title"
|
android:label="@string/restore_title"
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:permission="com.stevesoltys.seedvault.RESTORE_BACKUP">
|
android:permission="com.stevesoltys.seedvault.RESTORE_BACKUP">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" />
|
<action android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" />
|
||||||
|
@ -131,11 +127,6 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".ui.check.AppCheckResultActivity"
|
|
||||||
android:label="@string/notification_checking_finished_title"
|
|
||||||
android:launchMode="singleTask"/>
|
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".transport.ConfigurableBackupTransportService"
|
android:name=".transport.ConfigurableBackupTransportService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
<!--
|
|
||||||
SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
SPDX-License-Identifier: Apache-2.0
|
|
||||||
-->
|
|
||||||
<configuration
|
|
||||||
xmlns="https://tony19.github.io/logback-android/xml"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="https://tony19.github.io/logback-android/xml https://cdn.jsdelivr.net/gh/tony19/logback-android/logback.xsd"
|
|
||||||
>
|
|
||||||
<appender name="logcat" class="ch.qos.logback.classic.android.LogcatAppender">
|
|
||||||
<tagEncoder>
|
|
||||||
<pattern>%logger{12}</pattern>
|
|
||||||
</tagEncoder>
|
|
||||||
<encoder>
|
|
||||||
<pattern>[%-20thread] %msg</pattern>
|
|
||||||
</encoder>
|
|
||||||
</appender>
|
|
||||||
|
|
||||||
<root level="DEBUG">
|
|
||||||
<appender-ref ref="logcat" />
|
|
||||||
</root>
|
|
||||||
</configuration>
|
|
||||||
|
|
|
@ -6,8 +6,6 @@
|
||||||
package com.stevesoltys.seedvault
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
|
import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
|
||||||
import android.app.ActivityManager
|
|
||||||
import android.app.ActivityManager.RunningAppProcessInfo
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.app.backup.BackupManager
|
import android.app.backup.BackupManager
|
||||||
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
|
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
|
||||||
|
@ -19,18 +17,16 @@ import android.os.ServiceManager.getService
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
import android.os.UserHandle
|
import android.os.UserHandle
|
||||||
import android.os.UserManager
|
import android.os.UserManager
|
||||||
import android.util.Log
|
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import com.stevesoltys.seedvault.MemoryLogger.getMemStr
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.backend.saf.storagePluginModuleSaf
|
|
||||||
import com.stevesoltys.seedvault.backend.webdav.storagePluginModuleWebDav
|
|
||||||
import com.stevesoltys.seedvault.crypto.cryptoModule
|
import com.stevesoltys.seedvault.crypto.cryptoModule
|
||||||
import com.stevesoltys.seedvault.header.headerModule
|
import com.stevesoltys.seedvault.header.headerModule
|
||||||
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.metadataModule
|
import com.stevesoltys.seedvault.metadata.metadataModule
|
||||||
import com.stevesoltys.seedvault.repo.repoModule
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.storagePluginModuleWebDav
|
||||||
import com.stevesoltys.seedvault.restore.install.installModule
|
import com.stevesoltys.seedvault.restore.install.installModule
|
||||||
import com.stevesoltys.seedvault.restore.restoreUiModule
|
import com.stevesoltys.seedvault.restore.restoreUiModule
|
||||||
import com.stevesoltys.seedvault.settings.AppListRetriever
|
import com.stevesoltys.seedvault.settings.AppListRetriever
|
||||||
|
@ -46,7 +42,6 @@ import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
|
||||||
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
|
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
|
||||||
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||||
import com.stevesoltys.seedvault.worker.workerModule
|
import com.stevesoltys.seedvault.worker.workerModule
|
||||||
import org.calyxos.seedvault.core.backends.BackendFactory
|
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.android.ext.koin.androidLogger
|
import org.koin.android.ext.koin.androidLogger
|
||||||
|
@ -66,15 +61,7 @@ open class App : Application() {
|
||||||
private val appModule = module {
|
private val appModule = module {
|
||||||
single { SettingsManager(this@App) }
|
single { SettingsManager(this@App) }
|
||||||
single { BackupNotificationManager(this@App) }
|
single { BackupNotificationManager(this@App) }
|
||||||
single { BackendManager(this@App, get(), get(), get()) }
|
single { StoragePluginManager(this@App, get(), get(), get()) }
|
||||||
single {
|
|
||||||
BackendFactory {
|
|
||||||
// uses context of the device's main user to be able to access USB storage
|
|
||||||
this@App.applicationContext.getStorageContext {
|
|
||||||
get<SettingsManager>().getSafProperties()?.isUsb == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
single { BackupStateManager(this@App) }
|
single { BackupStateManager(this@App) }
|
||||||
single { Clock() }
|
single { Clock() }
|
||||||
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
|
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
|
||||||
|
@ -85,17 +72,16 @@ open class App : Application() {
|
||||||
app = this@App,
|
app = this@App,
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
keyManager = get(),
|
keyManager = get(),
|
||||||
backendManager = get(),
|
pluginManager = get(),
|
||||||
|
metadataManager = get(),
|
||||||
appListRetriever = get(),
|
appListRetriever = get(),
|
||||||
storageBackup = get(),
|
storageBackup = get(),
|
||||||
backupManager = get(),
|
backupManager = get(),
|
||||||
|
backupInitializer = get(),
|
||||||
backupStateManager = get(),
|
backupStateManager = get(),
|
||||||
checker = get(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModel {
|
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
|
||||||
RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get(), get())
|
|
||||||
}
|
|
||||||
viewModel {
|
viewModel {
|
||||||
BackupStorageViewModel(
|
BackupStorageViewModel(
|
||||||
app = this@App,
|
app = this@App,
|
||||||
|
@ -105,7 +91,7 @@ open class App : Application() {
|
||||||
safHandler = get(),
|
safHandler = get(),
|
||||||
webDavHandler = get(),
|
webDavHandler = get(),
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
backendManager = get(),
|
storagePluginManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
|
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
|
||||||
|
@ -115,7 +101,6 @@ open class App : Application() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||||
startKoin()
|
startKoin()
|
||||||
if (!isTest) migrateToOwnScheduling()
|
|
||||||
if (isDebugBuild()) {
|
if (isDebugBuild()) {
|
||||||
StrictMode.setThreadPolicy(
|
StrictMode.setThreadPolicy(
|
||||||
StrictMode.ThreadPolicy.Builder()
|
StrictMode.ThreadPolicy.Builder()
|
||||||
|
@ -131,6 +116,10 @@ open class App : Application() {
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
permitDiskReads {
|
||||||
|
migrateTokenFromMetadataToSettingsManager()
|
||||||
|
}
|
||||||
|
if (!isTest) migrateToOwnScheduling()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun startKoin() = startKoin {
|
protected open fun startKoin() = startKoin {
|
||||||
|
@ -149,24 +138,29 @@ open class App : Application() {
|
||||||
restoreModule,
|
restoreModule,
|
||||||
installModule,
|
installModule,
|
||||||
storageModule,
|
storageModule,
|
||||||
repoModule,
|
|
||||||
workerModule,
|
workerModule,
|
||||||
restoreUiModule,
|
restoreUiModule,
|
||||||
appModule
|
appModule
|
||||||
)
|
)
|
||||||
|
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
|
private val metadataManager: MetadataManager by inject()
|
||||||
private val backupManager: IBackupManager by inject()
|
private val backupManager: IBackupManager by inject()
|
||||||
private val backendManager: BackendManager by inject()
|
private val pluginManager: StoragePluginManager by inject()
|
||||||
private val backupStateManager: BackupStateManager by inject()
|
private val backupStateManager: BackupStateManager by inject()
|
||||||
|
|
||||||
override fun onTrimMemory(level: Int) {
|
/**
|
||||||
Log.w("Seedvault", "onTrimMemory($level) ${getMemStr()}")
|
* The responsibility for the current token was moved to the [SettingsManager]
|
||||||
val processInfo = RunningAppProcessInfo()
|
* in the end of 2020.
|
||||||
ActivityManager.getMyMemoryState(processInfo)
|
* This method migrates the token for existing installs and can be removed
|
||||||
Log.w("Seedvault", " lastTrimLevel: ${processInfo.lastTrimLevel}")
|
* after sufficient time has passed.
|
||||||
Log.w("Seedvault", " importance: ${processInfo.importance}")
|
*/
|
||||||
super.onTrimMemory(level)
|
private fun migrateTokenFromMetadataToSettingsManager() {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val token = metadataManager.getBackupToken()
|
||||||
|
if (token != 0L && settingsManager.getToken() == null) {
|
||||||
|
settingsManager.setNewToken(token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -176,13 +170,13 @@ open class App : Application() {
|
||||||
protected open fun migrateToOwnScheduling() {
|
protected open fun migrateToOwnScheduling() {
|
||||||
if (!backupStateManager.isFrameworkSchedulingEnabled) { // already on own scheduling
|
if (!backupStateManager.isFrameworkSchedulingEnabled) { // already on own scheduling
|
||||||
// fix things for removable drive users who had a job scheduled here before
|
// fix things for removable drive users who had a job scheduled here before
|
||||||
if (backendManager.isOnRemovableDrive) AppBackupWorker.unschedule(applicationContext)
|
if (pluginManager.isOnRemovableDrive) AppBackupWorker.unschedule(applicationContext)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (backupManager.currentTransport == TRANSPORT_ID) {
|
if (backupManager.currentTransport == TRANSPORT_ID) {
|
||||||
backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false)
|
backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false)
|
||||||
if (backupManager.isBackupEnabled && !backendManager.isOnRemovableDrive) {
|
if (backupManager.isBackupEnabled && !pluginManager.isOnRemovableDrive) {
|
||||||
AppBackupWorker.schedule(applicationContext, settingsManager, UPDATE)
|
AppBackupWorker.schedule(applicationContext, settingsManager, UPDATE)
|
||||||
}
|
}
|
||||||
// cancel old D2D worker
|
// cancel old D2D worker
|
||||||
|
@ -197,7 +191,6 @@ const val ANCESTRAL_RECORD_KEY = "@ancestral_record@"
|
||||||
const val NO_DATA_END_SENTINEL = "@end@"
|
const val NO_DATA_END_SENTINEL = "@end@"
|
||||||
const val GLOBAL_METADATA_KEY = "@meta@"
|
const val GLOBAL_METADATA_KEY = "@meta@"
|
||||||
const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED
|
const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED
|
||||||
const val ERROR_BACKUP_NOT_ALLOWED: Int = BackupManager.ERROR_BACKUP_NOT_ALLOWED
|
|
||||||
|
|
||||||
// TODO this doesn't work for LineageOS as they do public debug builds
|
// TODO this doesn't work for LineageOS as they do public debug builds
|
||||||
fun isDebugBuild() = Build.TYPE == "userdebug"
|
fun isDebugBuild() = Build.TYPE == "userdebug"
|
||||||
|
@ -220,10 +213,6 @@ fun <T> permitDiskReads(func: () -> T): T {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hack to allow other profiles access to USB backend.
|
|
||||||
* @return the context of the device's main user, so use with great care!
|
|
||||||
*/
|
|
||||||
@Suppress("MissingPermission")
|
@Suppress("MissingPermission")
|
||||||
fun Context.getStorageContext(isUsbStorage: () -> Boolean): Context {
|
fun Context.getStorageContext(isUsbStorage: () -> Boolean): Context {
|
||||||
if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED && isUsbStorage()) {
|
if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED && isUsbStorage()) {
|
||||||
|
|
|
@ -13,26 +13,23 @@ import android.app.backup.BackupManagerMonitor.LOG_EVENT_ID_ERROR_PREFLIGHT
|
||||||
import android.app.backup.IBackupManagerMonitor
|
import android.app.backup.IBackupManagerMonitor
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.util.Log.DEBUG
|
||||||
|
|
||||||
private val TAG = BackupMonitor::class.java.name
|
private val TAG = BackupMonitor::class.java.name
|
||||||
|
|
||||||
open class BackupMonitor : IBackupManagerMonitor.Stub() {
|
class BackupMonitor : IBackupManagerMonitor.Stub() {
|
||||||
|
|
||||||
override fun onEvent(bundle: Bundle) {
|
override fun onEvent(bundle: Bundle) {
|
||||||
onEvent(
|
val id = bundle.getInt(EXTRA_LOG_EVENT_ID)
|
||||||
id = bundle.getInt(EXTRA_LOG_EVENT_ID),
|
val packageName = bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME, "?")
|
||||||
category = bundle.getInt(EXTRA_LOG_EVENT_CATEGORY),
|
|
||||||
packageName = bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME),
|
|
||||||
bundle = bundle,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
open fun onEvent(id: Int, category: Int, packageName: String?, bundle: Bundle) {
|
|
||||||
Log.d(TAG, "${packageName?.padEnd(64, ' ')} cat: $category id: $id")
|
|
||||||
if (id == LOG_EVENT_ID_ERROR_PREFLIGHT) {
|
if (id == LOG_EVENT_ID_ERROR_PREFLIGHT) {
|
||||||
val preflightResult = bundle.getLong(EXTRA_LOG_PREFLIGHT_ERROR, -1)
|
val preflightResult = bundle.getLong(EXTRA_LOG_PREFLIGHT_ERROR, -1)
|
||||||
Log.w(TAG, "Pre-flight error from $packageName: $preflightResult")
|
Log.w(TAG, "Pre-flight error from $packageName: $preflightResult")
|
||||||
}
|
}
|
||||||
|
if (!Log.isLoggable(TAG, DEBUG)) return
|
||||||
|
Log.d(TAG, "ID: $id")
|
||||||
|
Log.d(TAG, "CATEGORY: " + bundle.getInt(EXTRA_LOG_EVENT_CATEGORY, -1))
|
||||||
|
Log.d(TAG, "PACKAGE: $packageName")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,9 +14,7 @@ import androidx.work.WorkInfo.State.RUNNING
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.stevesoltys.seedvault.storage.StorageBackupService
|
import com.stevesoltys.seedvault.storage.StorageBackupService
|
||||||
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService
|
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService
|
||||||
import com.stevesoltys.seedvault.worker.AppBackupPruneWorker
|
|
||||||
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
|
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
|
||||||
import com.stevesoltys.seedvault.worker.AppCheckerWorker
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
|
||||||
|
@ -33,28 +31,14 @@ class BackupStateManager(
|
||||||
flow = ConfigurableBackupTransportService.isRunning,
|
flow = ConfigurableBackupTransportService.isRunning,
|
||||||
flow2 = StorageBackupService.isRunning,
|
flow2 = StorageBackupService.isRunning,
|
||||||
flow3 = workManager.getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME),
|
flow3 = workManager.getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME),
|
||||||
) { appBackupRunning, filesBackupRunning, workInfo1 ->
|
) { appBackupRunning, filesBackupRunning, workInfos ->
|
||||||
val workInfoState1 = workInfo1.getOrNull(0)?.state
|
val workInfoState = workInfos.getOrNull(0)?.state
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG, "appBackupRunning: $appBackupRunning, " +
|
TAG, "appBackupRunning: $appBackupRunning, " +
|
||||||
"filesBackupRunning: $filesBackupRunning, " +
|
"filesBackupRunning: $filesBackupRunning, " +
|
||||||
"appBackupWorker: ${workInfoState1?.name}"
|
"workInfoState: ${workInfoState?.name}"
|
||||||
)
|
)
|
||||||
appBackupRunning || filesBackupRunning || workInfoState1 == RUNNING
|
appBackupRunning || filesBackupRunning || workInfoState == RUNNING
|
||||||
}
|
|
||||||
|
|
||||||
val isCheckOrPruneRunning: Flow<Boolean> = combine(
|
|
||||||
flow = workManager.getWorkInfosForUniqueWorkFlow(AppBackupPruneWorker.UNIQUE_WORK_NAME),
|
|
||||||
flow2 = workManager.getWorkInfosForUniqueWorkFlow(AppCheckerWorker.UNIQUE_WORK_NAME),
|
|
||||||
) { pruneInfo, checkInfo ->
|
|
||||||
val pruneInfoState = pruneInfo.getOrNull(0)?.state
|
|
||||||
val checkInfoState = checkInfo.getOrNull(0)?.state
|
|
||||||
Log.i(
|
|
||||||
TAG,
|
|
||||||
"pruneBackupWorker: ${pruneInfoState?.name}, " +
|
|
||||||
"appCheckerWorker: ${checkInfoState?.name}"
|
|
||||||
)
|
|
||||||
pruneInfoState == RUNNING || checkInfoState == RUNNING
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val isAutoRestoreEnabled: Boolean
|
val isAutoRestoreEnabled: Boolean
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
object MemoryLogger {
|
|
||||||
|
|
||||||
fun log() {
|
|
||||||
Log.d("MemoryLogger", getMemStr())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMemStr(): String {
|
|
||||||
val r = Runtime.getRuntime()
|
|
||||||
val total = r.totalMemory() / 1024 / 1024
|
|
||||||
val free = r.freeMemory() / 1024 / 1024
|
|
||||||
val max = r.maxMemory() / 1024 / 1024
|
|
||||||
val used = total - free
|
|
||||||
return "$free MiB free - $used of $total (max $max)"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -20,10 +20,14 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat.startForegroundService
|
||||||
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.settings.FlashDrive
|
import com.stevesoltys.seedvault.settings.FlashDrive
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.storage.StorageBackupService
|
||||||
|
import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP
|
||||||
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
|
||||||
import com.stevesoltys.seedvault.worker.BackupRequester.Companion.requestFilesAndAppBackup
|
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||||
import org.koin.core.context.GlobalContext.get
|
import org.koin.core.context.GlobalContext.get
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
|
@ -33,6 +37,7 @@ class UsbIntentReceiver : UsbMonitor() {
|
||||||
|
|
||||||
// using KoinComponent would crash robolectric tests :(
|
// using KoinComponent would crash robolectric tests :(
|
||||||
private val settingsManager: SettingsManager by lazy { get().get() }
|
private val settingsManager: SettingsManager by lazy { get().get() }
|
||||||
|
private val metadataManager: MetadataManager by lazy { get().get() }
|
||||||
private val backupManager: IBackupManager by lazy { get().get() }
|
private val backupManager: IBackupManager by lazy { get().get() }
|
||||||
|
|
||||||
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
|
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
|
||||||
|
@ -42,15 +47,14 @@ class UsbIntentReceiver : UsbMonitor() {
|
||||||
val attachedFlashDrive = FlashDrive.from(device)
|
val attachedFlashDrive = FlashDrive.from(device)
|
||||||
return if (savedFlashDrive == attachedFlashDrive) {
|
return if (savedFlashDrive == attachedFlashDrive) {
|
||||||
Log.d(TAG, "Matches stored device, checking backup time...")
|
Log.d(TAG, "Matches stored device, checking backup time...")
|
||||||
val lastBackupTime = settingsManager.lastBackupTime.value ?: 0
|
val backupMillis = System.currentTimeMillis() - metadataManager.getLastBackupTime()
|
||||||
val backupMillis = System.currentTimeMillis() - lastBackupTime
|
|
||||||
if (backupMillis >= settingsManager.backupFrequencyInMillis) {
|
if (backupMillis >= settingsManager.backupFrequencyInMillis) {
|
||||||
Log.d(TAG, "Last backup older than it should be, requesting a backup...")
|
Log.d(TAG, "Last backup older than it should be, requesting a backup...")
|
||||||
Log.d(TAG, " ${Date(lastBackupTime)}")
|
Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "We have a recent backup, not requesting a new one.")
|
Log.d(TAG, "We have a recent backup, not requesting a new one.")
|
||||||
Log.d(TAG, " ${Date(lastBackupTime)}")
|
Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -60,7 +64,16 @@ class UsbIntentReceiver : UsbMonitor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
|
override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
|
||||||
requestFilesAndAppBackup(context, settingsManager, backupManager)
|
if (settingsManager.isStorageBackupEnabled()) {
|
||||||
|
val i = Intent(context, StorageBackupService::class.java)
|
||||||
|
// this starts an app backup afterwards
|
||||||
|
i.putExtra(EXTRA_START_APP_BACKUP, true)
|
||||||
|
startForegroundService(context, i)
|
||||||
|
} else if (backupManager.isBackupEnabled) {
|
||||||
|
AppBackupWorker.scheduleNow(context, reschedule = false)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Neither files nor app backup enabled, do nothing.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -76,7 +89,7 @@ abstract class UsbMonitor : BroadcastReceiver() {
|
||||||
if (intent.action == ACTION_USB_DEVICE_ATTACHED ||
|
if (intent.action == ACTION_USB_DEVICE_ATTACHED ||
|
||||||
intent.action == ACTION_USB_DEVICE_DETACHED
|
intent.action == ACTION_USB_DEVICE_DETACHED
|
||||||
) {
|
) {
|
||||||
val device = intent.extras?.getParcelable(EXTRA_DEVICE, UsbDevice::class.java) ?: return
|
val device = intent.extras?.getParcelable<UsbDevice>(EXTRA_DEVICE) ?: return
|
||||||
Log.d(TAG, "New USB mass-storage device attached.")
|
Log.d(TAG, "New USB mass-storage device attached.")
|
||||||
device.log()
|
device.log()
|
||||||
|
|
||||||
|
|
|
@ -5,33 +5,23 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.crypto
|
package com.stevesoltys.seedvault.crypto
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.provider.Settings.Secure.ANDROID_ID
|
|
||||||
import com.google.crypto.tink.subtle.AesGcmHkdfStreaming
|
import com.google.crypto.tink.subtle.AesGcmHkdfStreaming
|
||||||
import com.stevesoltys.seedvault.encodeBase64
|
import com.stevesoltys.seedvault.encodeBase64
|
||||||
import com.stevesoltys.seedvault.header.HeaderReader
|
import com.stevesoltys.seedvault.header.HeaderReader
|
||||||
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
||||||
import com.stevesoltys.seedvault.header.MAX_VERSION_HEADER_SIZE
|
import com.stevesoltys.seedvault.header.MAX_VERSION_HEADER_SIZE
|
||||||
import com.stevesoltys.seedvault.header.SegmentHeader
|
import com.stevesoltys.seedvault.header.SegmentHeader
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
|
||||||
import com.stevesoltys.seedvault.header.VersionHeader
|
import com.stevesoltys.seedvault.header.VersionHeader
|
||||||
import org.calyxos.seedvault.core.crypto.CoreCrypto
|
import org.calyxos.backup.storage.crypto.StreamCrypto
|
||||||
import org.calyxos.seedvault.core.crypto.CoreCrypto.ALGORITHM_HMAC
|
import org.calyxos.backup.storage.crypto.StreamCrypto.deriveStreamKey
|
||||||
import org.calyxos.seedvault.core.crypto.CoreCrypto.deriveKey
|
|
||||||
import org.calyxos.seedvault.core.toByteArrayFromHex
|
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import java.io.EOFException
|
import java.io.EOFException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.security.GeneralSecurityException
|
import java.security.GeneralSecurityException
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.NoSuchAlgorithmException
|
import java.security.NoSuchAlgorithmException
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import javax.crypto.Mac
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -57,18 +47,13 @@ internal interface Crypto {
|
||||||
*/
|
*/
|
||||||
fun getRandomBytes(size: Int): ByteArray
|
fun getRandomBytes(size: Int): ByteArray
|
||||||
|
|
||||||
/**
|
fun getNameForPackage(salt: String, packageName: String): String
|
||||||
* Returns the ID of the backup repository as a 64 char hex string.
|
|
||||||
*/
|
|
||||||
val repoId: String
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A secret key of size [KEY_SIZE_BYTES]
|
* Returns the name that identifies an APK in the backup storage plugin.
|
||||||
* only used to create a gear table specific to each main key.
|
* @param suffix empty string for normal APKs and the name of the split in case of an APK split
|
||||||
*/
|
*/
|
||||||
val gearTableKey: ByteArray
|
fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String
|
||||||
|
|
||||||
fun sha256(bytes: ByteArray): ByteArray
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a [AesGcmHkdfStreaming] encrypting stream
|
* Returns a [AesGcmHkdfStreaming] encrypting stream
|
||||||
|
@ -90,29 +75,6 @@ internal interface Crypto {
|
||||||
associatedData: ByteArray,
|
associatedData: ByteArray,
|
||||||
): InputStream
|
): InputStream
|
||||||
|
|
||||||
fun getAdForVersion(version: Byte = VERSION): ByteArray
|
|
||||||
|
|
||||||
@Deprecated("only for v1")
|
|
||||||
fun getNameForPackage(salt: String, packageName: String): String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the name that identifies an APK in the backup storage plugin.
|
|
||||||
* @param suffix empty string for normal APKs and the name of the split in case of an APK split
|
|
||||||
*/
|
|
||||||
@Deprecated("only for v1")
|
|
||||||
fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a [AesGcmHkdfStreaming] decrypting stream
|
|
||||||
* that gets decrypted and authenticated the given associated data.
|
|
||||||
*/
|
|
||||||
@Deprecated("only for v1")
|
|
||||||
@Throws(IOException::class, GeneralSecurityException::class)
|
|
||||||
fun newDecryptingStreamV1(
|
|
||||||
inputStream: InputStream,
|
|
||||||
associatedData: ByteArray,
|
|
||||||
): InputStream
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads and decrypts a [VersionHeader] from the given [InputStream]
|
* Reads and decrypts a [VersionHeader] from the given [InputStream]
|
||||||
* and ensures that the expected version, package name and key match
|
* and ensures that the expected version, package name and key match
|
||||||
|
@ -160,71 +122,30 @@ internal const val TYPE_BACKUP_KV: Byte = 0x01
|
||||||
internal const val TYPE_BACKUP_FULL: Byte = 0x02
|
internal const val TYPE_BACKUP_FULL: Byte = 0x02
|
||||||
internal const val TYPE_ICONS: Byte = 0x03
|
internal const val TYPE_ICONS: Byte = 0x03
|
||||||
|
|
||||||
@SuppressLint("HardwareIds")
|
|
||||||
internal class CryptoImpl(
|
internal class CryptoImpl(
|
||||||
context: Context,
|
|
||||||
private val keyManager: KeyManager,
|
private val keyManager: KeyManager,
|
||||||
private val cipherFactory: CipherFactory,
|
private val cipherFactory: CipherFactory,
|
||||||
private val headerReader: HeaderReader,
|
private val headerReader: HeaderReader,
|
||||||
private val androidId: String = Settings.Secure.getString(context.contentResolver, ANDROID_ID),
|
|
||||||
) : Crypto {
|
) : Crypto {
|
||||||
|
|
||||||
private val keyV1: ByteArray by lazy {
|
private val key: ByteArray by lazy {
|
||||||
deriveKey(keyManager.getMainKey(), "app data key".toByteArray())
|
deriveStreamKey(keyManager.getMainKey(), "app data key".toByteArray())
|
||||||
}
|
}
|
||||||
private val streamKey: ByteArray by lazy {
|
private val secureRandom: SecureRandom by lazy { SecureRandom() }
|
||||||
deriveKey(keyManager.getMainKey(), "app backup stream key".toByteArray())
|
|
||||||
}
|
|
||||||
private val secureRandom: SecureRandom by lazy { SecureRandom.getInstanceStrong() }
|
|
||||||
|
|
||||||
override fun getRandomBytes(size: Int) = ByteArray(size).apply {
|
override fun getRandomBytes(size: Int) = ByteArray(size).apply {
|
||||||
secureRandom.nextBytes(this)
|
secureRandom.nextBytes(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The ID of the backup repository tied to this user/device via [ANDROID_ID]
|
|
||||||
* and the current [KeyManager.getMainKey].
|
|
||||||
*
|
|
||||||
* Attention: If the main key ever changes, we need to kill our process,
|
|
||||||
* so all lazy values that depend on that key or the [gearTableKey] get reinitialized.
|
|
||||||
*/
|
|
||||||
override val repoId: String by lazy {
|
|
||||||
val repoIdKey =
|
|
||||||
deriveKey(keyManager.getMainKey(), "app backup repoId key".toByteArray())
|
|
||||||
val hmacHasher: Mac = Mac.getInstance(ALGORITHM_HMAC).apply {
|
|
||||||
init(SecretKeySpec(repoIdKey, ALGORITHM_HMAC))
|
|
||||||
}
|
|
||||||
hmacHasher.doFinal(androidId.toByteArrayFromHex()).toHexString()
|
|
||||||
}
|
|
||||||
|
|
||||||
override val gearTableKey: ByteArray
|
|
||||||
get() = deriveKey(keyManager.getMainKey(), "app backup gear table key".toByteArray())
|
|
||||||
|
|
||||||
override fun newEncryptingStream(
|
|
||||||
outputStream: OutputStream,
|
|
||||||
associatedData: ByteArray,
|
|
||||||
): OutputStream = CoreCrypto.newEncryptingStream(streamKey, outputStream, associatedData)
|
|
||||||
|
|
||||||
override fun newDecryptingStream(
|
|
||||||
inputStream: InputStream,
|
|
||||||
associatedData: ByteArray,
|
|
||||||
): InputStream = CoreCrypto.newDecryptingStream(streamKey, inputStream, associatedData)
|
|
||||||
|
|
||||||
override fun getAdForVersion(version: Byte): ByteArray = ByteBuffer.allocate(1)
|
|
||||||
.put(version)
|
|
||||||
.array()
|
|
||||||
|
|
||||||
@Deprecated("only for v1")
|
|
||||||
override fun getNameForPackage(salt: String, packageName: String): String {
|
override fun getNameForPackage(salt: String, packageName: String): String {
|
||||||
return sha256("$salt$packageName".toByteArray()).encodeBase64()
|
return sha256("$salt$packageName".toByteArray()).encodeBase64()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("only for v1")
|
|
||||||
override fun getNameForApk(salt: String, packageName: String, suffix: String): String {
|
override fun getNameForApk(salt: String, packageName: String, suffix: String): String {
|
||||||
return sha256("${salt}APK$packageName$suffix".toByteArray()).encodeBase64()
|
return sha256("${salt}APK$packageName$suffix".toByteArray()).encodeBase64()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sha256(bytes: ByteArray): ByteArray {
|
private fun sha256(bytes: ByteArray): ByteArray {
|
||||||
val messageDigest: MessageDigest = try {
|
val messageDigest: MessageDigest = try {
|
||||||
MessageDigest.getInstance("SHA-256")
|
MessageDigest.getInstance("SHA-256")
|
||||||
} catch (e: NoSuchAlgorithmException) {
|
} catch (e: NoSuchAlgorithmException) {
|
||||||
|
@ -234,12 +155,21 @@ internal class CryptoImpl(
|
||||||
return messageDigest.digest()
|
return messageDigest.digest()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("only for v1")
|
|
||||||
@Throws(IOException::class, GeneralSecurityException::class)
|
@Throws(IOException::class, GeneralSecurityException::class)
|
||||||
override fun newDecryptingStreamV1(
|
override fun newEncryptingStream(
|
||||||
|
outputStream: OutputStream,
|
||||||
|
associatedData: ByteArray,
|
||||||
|
): OutputStream {
|
||||||
|
return StreamCrypto.newEncryptingStream(key, outputStream, associatedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class, GeneralSecurityException::class)
|
||||||
|
override fun newDecryptingStream(
|
||||||
inputStream: InputStream,
|
inputStream: InputStream,
|
||||||
associatedData: ByteArray,
|
associatedData: ByteArray,
|
||||||
): InputStream = CoreCrypto.newDecryptingStream(keyV1, inputStream, associatedData)
|
): InputStream {
|
||||||
|
return StreamCrypto.newDecryptingStream(key, inputStream, associatedData)
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
@Throws(IOException::class, SecurityException::class)
|
@Throws(IOException::class, SecurityException::class)
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.crypto
|
package com.stevesoltys.seedvault.crypto
|
||||||
|
|
||||||
import org.koin.android.ext.koin.androidContext
|
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
|
|
||||||
|
@ -21,5 +20,5 @@ val cryptoModule = module {
|
||||||
}
|
}
|
||||||
KeyManagerImpl(keyStore)
|
KeyManagerImpl(keyStore)
|
||||||
}
|
}
|
||||||
single<Crypto> { CryptoImpl(androidContext(), get(), get(), get()) }
|
single<Crypto> { CryptoImpl(get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ internal const val KEY_ALIAS_MAIN = "com.stevesoltys.seedvault.main"
|
||||||
private const val KEY_ALGORITHM_BACKUP = "AES"
|
private const val KEY_ALGORITHM_BACKUP = "AES"
|
||||||
private const val KEY_ALGORITHM_MAIN = "HmacSHA256"
|
private const val KEY_ALGORITHM_MAIN = "HmacSHA256"
|
||||||
|
|
||||||
interface KeyManager : org.calyxos.seedvault.core.crypto.KeyManager {
|
interface KeyManager {
|
||||||
/**
|
/**
|
||||||
* Store a new backup key derived from the given [seed].
|
* Store a new backup key derived from the given [seed].
|
||||||
*
|
*
|
||||||
|
@ -57,6 +57,14 @@ interface KeyManager : org.calyxos.seedvault.core.crypto.KeyManager {
|
||||||
* because the key can not leave the [KeyStore]'s hardware security module.
|
* because the key can not leave the [KeyStore]'s hardware security module.
|
||||||
*/
|
*/
|
||||||
fun getBackupKey(): SecretKey
|
fun getBackupKey(): SecretKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the main key, so it can be used for deriving sub-keys.
|
||||||
|
*
|
||||||
|
* Note that any attempt to export the key will return null or an empty [ByteArray],
|
||||||
|
* because the key can not leave the [KeyStore]'s hardware security module.
|
||||||
|
*/
|
||||||
|
fun getMainKey(): SecretKey
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class KeyManagerImpl(
|
internal class KeyManagerImpl(
|
||||||
|
|
|
@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_FULL
|
||||||
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
|
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
internal const val VERSION: Byte = 2
|
internal const val VERSION: Byte = 1
|
||||||
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
|
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
|
||||||
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
|
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
|
||||||
internal const val MAX_VERSION_HEADER_SIZE =
|
internal const val MAX_VERSION_HEADER_SIZE =
|
||||||
|
|
|
@ -8,12 +8,8 @@ package com.stevesoltys.seedvault.metadata
|
||||||
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.stevesoltys.seedvault.crypto.TYPE_METADATA
|
import com.stevesoltys.seedvault.crypto.TYPE_METADATA
|
||||||
import com.stevesoltys.seedvault.encodeBase64
|
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot
|
|
||||||
import com.stevesoltys.seedvault.repo.hexFromProto
|
|
||||||
import com.stevesoltys.seedvault.worker.BASE_SPLIT
|
|
||||||
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
|
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
@ -30,23 +26,6 @@ data class BackupMetadata(
|
||||||
internal var d2dBackup: Boolean = false,
|
internal var d2dBackup: Boolean = false,
|
||||||
internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(),
|
internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(),
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromSnapshot(s: Snapshot) = BackupMetadata(
|
|
||||||
version = s.version.toByte(),
|
|
||||||
token = s.token,
|
|
||||||
salt = "",
|
|
||||||
time = s.token,
|
|
||||||
androidVersion = s.sdkInt,
|
|
||||||
androidIncremental = s.androidIncremental,
|
|
||||||
deviceName = "${s.name} - ${s.user}",
|
|
||||||
d2dBackup = s.d2D,
|
|
||||||
packageMetadataMap = s.appsMap.mapValues { (_, app) ->
|
|
||||||
PackageMetadata.fromSnapshot(app)
|
|
||||||
} as PackageMetadataMap
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val size: Long
|
val size: Long
|
||||||
get() = packageMetadataMap.values.sumOf { m ->
|
get() = packageMetadataMap.values.sumOf { m ->
|
||||||
(m.size ?: 0L) + (m.splits?.sumOf { it.size ?: 0L } ?: 0L)
|
(m.size ?: 0L) + (m.splits?.sumOf { it.size ?: 0L } ?: 0L)
|
||||||
|
@ -112,56 +91,12 @@ data class PackageMetadata(
|
||||||
internal val version: Long? = null,
|
internal val version: Long? = null,
|
||||||
internal val installer: String? = null,
|
internal val installer: String? = null,
|
||||||
internal val splits: List<ApkSplit>? = null,
|
internal val splits: List<ApkSplit>? = null,
|
||||||
internal val baseApkChunkIds: List<String>? = null, // used for v2
|
|
||||||
internal val chunkIds: List<String>? = null, // used for v2
|
|
||||||
internal val sha256: String? = null,
|
internal val sha256: String? = null,
|
||||||
internal val signatures: List<String>? = null,
|
internal val signatures: List<String>? = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromSnapshot(app: Snapshot.App) = PackageMetadata(
|
|
||||||
time = app.time,
|
|
||||||
backupType = app.type.toBackupType(),
|
|
||||||
name = app.name,
|
|
||||||
chunkIds = app.chunkIdsList.hexFromProto(),
|
|
||||||
system = app.system,
|
|
||||||
isLaunchableSystemApp = app.launchableSystemApp,
|
|
||||||
version = app.apk.versionCode,
|
|
||||||
installer = app.apk.installer.takeIf { it.isNotEmpty() },
|
|
||||||
baseApkChunkIds = run {
|
|
||||||
val baseChunk = app.apk.splitsList.find { it.name == BASE_SPLIT }
|
|
||||||
if (baseChunk == null || baseChunk.chunkIdsCount == 0) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
baseChunk.chunkIdsList.hexFromProto()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
splits = app.apk.splitsList.filter { it.name != BASE_SPLIT }.map {
|
|
||||||
ApkSplit(
|
|
||||||
name = it.name,
|
|
||||||
size = null,
|
|
||||||
sha256 = "",
|
|
||||||
chunkIds = if (it.chunkIdsCount == 0) null else it.chunkIdsList.hexFromProto()
|
|
||||||
)
|
|
||||||
}.takeIf { it.isNotEmpty() }, // expected null if there are no splits
|
|
||||||
sha256 = null,
|
|
||||||
signatures = app.apk.signaturesList.map { it.toByteArray().encodeBase64() }.takeIf {
|
|
||||||
it.isNotEmpty()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
fun Snapshot.BackupType.toBackupType() = when (this) {
|
|
||||||
Snapshot.BackupType.FULL -> BackupType.FULL
|
|
||||||
Snapshot.BackupType.KV -> BackupType.KV
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val isInternalSystem: Boolean = system && !isLaunchableSystemApp
|
val isInternalSystem: Boolean = system && !isLaunchableSystemApp
|
||||||
fun hasApk(): Boolean {
|
fun hasApk(): Boolean {
|
||||||
return version != null && // v2 doesn't use sha256 here
|
return version != null && sha256 != null && signatures != null
|
||||||
(sha256 != null || baseApkChunkIds?.isNotEmpty() == true) &&
|
|
||||||
signatures != null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,7 +104,6 @@ data class ApkSplit(
|
||||||
val name: String,
|
val name: String,
|
||||||
val size: Long?,
|
val size: Long?,
|
||||||
val sha256: String,
|
val sha256: String,
|
||||||
val chunkIds: List<String>? = null, // used for v2
|
|
||||||
// There's also a revisionCode, but it doesn't seem to be used just yet
|
// There's also a revisionCode, but it doesn't seem to be used just yet
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -8,11 +8,23 @@ package com.stevesoltys.seedvault.metadata
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Context.MODE_PRIVATE
|
import android.content.Context.MODE_PRIVATE
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.UserManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.distinctUntilChanged
|
||||||
import com.stevesoltys.seedvault.Clock
|
import com.stevesoltys.seedvault.Clock
|
||||||
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
|
import com.stevesoltys.seedvault.encodeBase64
|
||||||
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
@ -27,50 +39,130 @@ internal const val METADATA_SALT_SIZE = 32
|
||||||
internal class MetadataManager(
|
internal class MetadataManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val clock: Clock,
|
private val clock: Clock,
|
||||||
|
private val crypto: Crypto,
|
||||||
private val metadataWriter: MetadataWriter,
|
private val metadataWriter: MetadataWriter,
|
||||||
private val metadataReader: MetadataReader,
|
private val metadataReader: MetadataReader,
|
||||||
|
private val packageService: PackageService,
|
||||||
|
private val settingsManager: SettingsManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val uninitializedMetadata = BackupMetadata(token = -42L, salt = "foo bar")
|
private val uninitializedMetadata = BackupMetadata(token = 0L, salt = "")
|
||||||
private var metadata: BackupMetadata = uninitializedMetadata
|
private var metadata: BackupMetadata = uninitializedMetadata
|
||||||
get() {
|
get() {
|
||||||
if (field == uninitializedMetadata) {
|
if (field == uninitializedMetadata) {
|
||||||
field = try {
|
field = try {
|
||||||
val m = getMetadataFromCache() ?: throw IOException()
|
getMetadataFromCache() ?: throw IOException()
|
||||||
if (m == uninitializedMetadata) m.copy(salt = "initialized")
|
|
||||||
else m
|
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
// This can happen if the storage location ran out of space
|
// This can happen if the storage location ran out of space
|
||||||
// or the app process got killed while writing the file.
|
// or the app process got killed while writing the file.
|
||||||
// It is hard to recover from this, so we try as best as we can here:
|
// It is hard to recover from this, so we try as best as we can here:
|
||||||
Log.e(TAG, "ERROR getting metadata cache, creating new file ", e)
|
Log.e(TAG, "ERROR getting metadata cache, creating new file ", e)
|
||||||
uninitializedMetadata.copy(salt = "initialized")
|
// This should cause requiresInit() return true
|
||||||
|
uninitializedMetadata.copy(version = (-1).toByte())
|
||||||
}
|
}
|
||||||
|
mLastBackupTime.postValue(field.time)
|
||||||
}
|
}
|
||||||
return field
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val backupSize: Long get() = metadata.size
|
||||||
|
|
||||||
|
private val launchableSystemApps by lazy {
|
||||||
|
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call this when initializing a new device.
|
||||||
|
*
|
||||||
|
* Existing [BackupMetadata] will be cleared
|
||||||
|
* and new metadata with the given [token] will be written to the internal cache
|
||||||
|
* with a fresh salt.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun onDeviceInitialization(token: Long) {
|
||||||
|
val salt = crypto.getRandomBytes(METADATA_SALT_SIZE).encodeBase64()
|
||||||
|
modifyCachedMetadata {
|
||||||
|
val userName = getUserName()
|
||||||
|
metadata = BackupMetadata(
|
||||||
|
token = token,
|
||||||
|
salt = salt,
|
||||||
|
deviceName = if (userName == null) {
|
||||||
|
"${Build.MANUFACTURER} ${Build.MODEL}"
|
||||||
|
} else {
|
||||||
|
"${Build.MANUFACTURER} ${Build.MODEL} - $userName"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call this after a package's APK has been backed up successfully.
|
||||||
|
*
|
||||||
|
* It updates the packages' metadata to the internal cache.
|
||||||
|
* You still need to call [uploadMetadata] to persist all local modifications.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun onApkBackedUp(
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
packageMetadata: PackageMetadata,
|
||||||
|
) {
|
||||||
|
val packageName = packageInfo.packageName
|
||||||
|
metadata.packageMetadataMap[packageName]?.let {
|
||||||
|
check(packageMetadata.version != null) {
|
||||||
|
"APK backup returned version null"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
|
||||||
|
?: PackageMetadata()
|
||||||
|
modifyCachedMetadata {
|
||||||
|
val isSystemApp = packageInfo.isSystemApp()
|
||||||
|
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
|
||||||
|
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||||
|
system = isSystemApp,
|
||||||
|
isLaunchableSystemApp = isSystemApp && launchableSystemApps.contains(packageName),
|
||||||
|
version = packageMetadata.version,
|
||||||
|
installer = packageMetadata.installer,
|
||||||
|
splits = packageMetadata.splits,
|
||||||
|
sha256 = packageMetadata.sha256,
|
||||||
|
signatures = packageMetadata.signatures
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call this after a package has been backed up successfully.
|
* Call this after a package has been backed up successfully.
|
||||||
*
|
*
|
||||||
* It updates the packages' metadata.
|
* It updates the packages' metadata
|
||||||
|
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
|
||||||
|
*
|
||||||
|
* Closing the [OutputStream] is the responsibility of the caller.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun onPackageBackedUp(
|
fun onPackageBackedUp(
|
||||||
packageInfo: PackageInfo,
|
packageInfo: PackageInfo,
|
||||||
type: BackupType?,
|
type: BackupType,
|
||||||
size: Long?,
|
size: Long?,
|
||||||
|
metadataOutputStream: OutputStream,
|
||||||
) {
|
) {
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
modifyCachedMetadata {
|
modifyMetadata(metadataOutputStream) {
|
||||||
val now = clock.time()
|
val now = clock.time()
|
||||||
|
metadata.time = now
|
||||||
|
metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
|
||||||
metadata.packageMetadataMap.getOrPut(packageName) {
|
metadata.packageMetadataMap.getOrPut(packageName) {
|
||||||
|
val isSystemApp = packageInfo.isSystemApp()
|
||||||
PackageMetadata(
|
PackageMetadata(
|
||||||
time = now,
|
time = now,
|
||||||
state = APK_AND_DATA,
|
state = APK_AND_DATA,
|
||||||
backupType = type,
|
backupType = type,
|
||||||
size = size,
|
size = size,
|
||||||
|
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||||
|
system = isSystemApp,
|
||||||
|
isLaunchableSystemApp = isSystemApp &&
|
||||||
|
launchableSystemApps.contains(packageName),
|
||||||
)
|
)
|
||||||
}.apply {
|
}.apply {
|
||||||
time = now
|
time = now
|
||||||
|
@ -78,6 +170,10 @@ internal class MetadataManager(
|
||||||
backupType = type
|
backupType = type
|
||||||
// don't override a previous K/V size, if there were no K/V changes
|
// don't override a previous K/V size, if there were no K/V changes
|
||||||
if (size != null) this.size = size
|
if (size != null) this.size = size
|
||||||
|
// update name, if none was set, yet (can happen while migrating to storing names)
|
||||||
|
if (this.name == null) {
|
||||||
|
this.name = packageInfo.applicationInfo?.loadLabel(context.packageManager)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -93,16 +189,21 @@ internal class MetadataManager(
|
||||||
internal fun onPackageBackupError(
|
internal fun onPackageBackupError(
|
||||||
packageInfo: PackageInfo,
|
packageInfo: PackageInfo,
|
||||||
packageState: PackageState,
|
packageState: PackageState,
|
||||||
|
metadataOutputStream: OutputStream,
|
||||||
backupType: BackupType? = null,
|
backupType: BackupType? = null,
|
||||||
) {
|
) {
|
||||||
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
|
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
|
||||||
modifyCachedMetadata {
|
modifyMetadata(metadataOutputStream) {
|
||||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||||
|
val isSystemApp = packageInfo.isSystemApp()
|
||||||
PackageMetadata(
|
PackageMetadata(
|
||||||
time = 0L,
|
time = 0L,
|
||||||
state = packageState,
|
state = packageState,
|
||||||
backupType = backupType,
|
backupType = backupType,
|
||||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||||
|
system = isSystemApp,
|
||||||
|
isLaunchableSystemApp = isSystemApp &&
|
||||||
|
launchableSystemApps.contains(packageInfo.packageName),
|
||||||
)
|
)
|
||||||
}.state = packageState
|
}.state = packageState
|
||||||
}
|
}
|
||||||
|
@ -112,6 +213,7 @@ internal class MetadataManager(
|
||||||
* Call this for all packages we can not back up for some reason.
|
* Call this for all packages we can not back up for some reason.
|
||||||
*
|
*
|
||||||
* It updates the packages' local metadata.
|
* It updates the packages' local metadata.
|
||||||
|
* You still need to call [uploadMetadata] to persist all local modifications.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
|
@ -120,10 +222,14 @@ internal class MetadataManager(
|
||||||
packageState: PackageState,
|
packageState: PackageState,
|
||||||
) = modifyCachedMetadata {
|
) = modifyCachedMetadata {
|
||||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||||
|
val isSystemApp = packageInfo.isSystemApp()
|
||||||
PackageMetadata(
|
PackageMetadata(
|
||||||
time = 0L,
|
time = 0L,
|
||||||
state = packageState,
|
state = packageState,
|
||||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||||
|
system = isSystemApp,
|
||||||
|
isLaunchableSystemApp = isSystemApp &&
|
||||||
|
launchableSystemApps.contains(packageInfo.packageName),
|
||||||
)
|
)
|
||||||
}.apply {
|
}.apply {
|
||||||
state = packageState
|
state = packageState
|
||||||
|
@ -134,15 +240,18 @@ internal class MetadataManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads metadata to given [metadataOutputStream] after performing local modifications.
|
||||||
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun getPackageMetadata(packageName: String): PackageMetadata? {
|
@Throws(IOException::class)
|
||||||
return metadata.packageMetadataMap[packageName]?.copy()
|
fun uploadMetadata(metadataOutputStream: OutputStream) {
|
||||||
|
metadataWriter.write(metadata, metadataOutputStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
private fun modifyCachedMetadata(modFun: () -> Unit) {
|
private fun modifyCachedMetadata(modFun: () -> Unit) {
|
||||||
val oldMetadata = metadata.copy(
|
val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
|
||||||
// copy map, otherwise it will re-use same reference
|
|
||||||
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
|
@ -156,6 +265,63 @@ internal class MetadataManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun modifyMetadata(metadataOutputStream: OutputStream, modFun: () -> Unit) {
|
||||||
|
val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
|
||||||
|
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
modFun.invoke()
|
||||||
|
metadataWriter.write(metadata, metadataOutputStream)
|
||||||
|
writeMetadataToCache()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(TAG, "Error writing metadata to storage", e)
|
||||||
|
// revert metadata and do not write it to cache
|
||||||
|
metadata = oldMetadata
|
||||||
|
throw IOException(e)
|
||||||
|
}
|
||||||
|
mLastBackupTime.postValue(metadata.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current backup token.
|
||||||
|
*
|
||||||
|
* If the token is 0L, it is not yet initialized and must not be used for anything.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
@Deprecated(
|
||||||
|
"Responsibility for current token moved to SettingsManager",
|
||||||
|
ReplaceWith("settingsManager.getToken()")
|
||||||
|
)
|
||||||
|
fun getBackupToken(): Long = metadata.token
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the last backup time in unix epoch milli seconds.
|
||||||
|
*
|
||||||
|
* Note that this might be a blocking I/O call.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun getLastBackupTime(): Long = mLastBackupTime.value ?: metadata.time
|
||||||
|
|
||||||
|
private val mLastBackupTime = MutableLiveData<Long>()
|
||||||
|
internal val lastBackupTime: LiveData<Long> = mLastBackupTime.distinctUntilChanged()
|
||||||
|
|
||||||
|
internal val salt: String
|
||||||
|
@Synchronized get() = metadata.salt
|
||||||
|
|
||||||
|
internal val requiresInit: Boolean
|
||||||
|
@Synchronized get() = metadata == uninitializedMetadata || metadata.version < VERSION
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getPackageMetadata(packageName: String): PackageMetadata? {
|
||||||
|
return metadata.packageMetadataMap[packageName]?.copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getPackagesBackupSize(): Long {
|
||||||
|
return metadata.packageMetadataMap.values.sumOf { it.size ?: 0L }
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
private fun getMetadataFromCache(): BackupMetadata? {
|
private fun getMetadataFromCache(): BackupMetadata? {
|
||||||
|
@ -181,4 +347,12 @@ internal class MetadataManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getUserName(): String? {
|
||||||
|
val perm = "android.permission.QUERY_USERS"
|
||||||
|
return if (context.checkSelfPermission(perm) == PERMISSION_GRANTED) {
|
||||||
|
val userManager = context.getSystemService(UserManager::class.java) ?: return null
|
||||||
|
userManager.userName
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val metadataModule = module {
|
val metadataModule = module {
|
||||||
single { MetadataManager(androidContext(), get(), get(), get()) }
|
single { MetadataManager(androidContext(), get(), get(), get(), get(), get(), get()) }
|
||||||
single<MetadataWriter> { MetadataWriterImpl() }
|
single<MetadataWriter> { MetadataWriterImpl(get()) }
|
||||||
single<MetadataReader> { MetadataReaderImpl(get()) }
|
single<MetadataReader> { MetadataReaderImpl(get()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
||||||
if (version == 0.toByte()) return readMetadataV0(inputStream, expectedToken)
|
if (version == 0.toByte()) return readMetadataV0(inputStream, expectedToken)
|
||||||
|
|
||||||
val metadataBytes = try {
|
val metadataBytes = try {
|
||||||
crypto.newDecryptingStreamV1(inputStream, getAD(version, expectedToken)).readBytes()
|
crypto.newDecryptingStream(inputStream, getAD(version, expectedToken)).readBytes()
|
||||||
} catch (e: GeneralSecurityException) {
|
} catch (e: GeneralSecurityException) {
|
||||||
throw DecryptionFailedException(e)
|
throw DecryptionFailedException(e)
|
||||||
}
|
}
|
||||||
|
@ -94,14 +94,14 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
||||||
val json = JSONObject(bytes.toString(Utf8))
|
val json = JSONObject(bytes.toString(Utf8))
|
||||||
// get backup metadata and check expectations
|
// get backup metadata and check expectations
|
||||||
val meta = json.getJSONObject(JSON_METADATA)
|
val meta = json.getJSONObject(JSON_METADATA)
|
||||||
val version = meta.optInt(JSON_METADATA_VERSION, VERSION.toInt()).toByte()
|
val version = meta.getInt(JSON_METADATA_VERSION).toByte()
|
||||||
if (expectedVersion != null && version != expectedVersion) {
|
if (expectedVersion != null && version != expectedVersion) {
|
||||||
throw SecurityException(
|
throw SecurityException(
|
||||||
"Invalid version '${version.toInt()}' in metadata," +
|
"Invalid version '${version.toInt()}' in metadata," +
|
||||||
"expected '${expectedVersion.toInt()}'."
|
"expected '${expectedVersion.toInt()}'."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val token = meta.optLong(JSON_METADATA_TOKEN, 0)
|
val token = meta.getLong(JSON_METADATA_TOKEN)
|
||||||
if (expectedToken != null && token != expectedToken) throw SecurityException(
|
if (expectedToken != null && token != expectedToken) throw SecurityException(
|
||||||
"Invalid token '$token' in metadata, expected '$expectedToken'."
|
"Invalid token '$token' in metadata, expected '$expectedToken'."
|
||||||
)
|
)
|
||||||
|
@ -157,11 +157,11 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
||||||
return BackupMetadata(
|
return BackupMetadata(
|
||||||
version = version,
|
version = version,
|
||||||
token = token,
|
token = token,
|
||||||
salt = if (version == 0.toByte()) "" else meta.optString(JSON_METADATA_SALT, ""),
|
salt = if (version == 0.toByte()) "" else meta.getString(JSON_METADATA_SALT),
|
||||||
time = meta.optLong(JSON_METADATA_TIME, -1),
|
time = meta.getLong(JSON_METADATA_TIME),
|
||||||
androidVersion = meta.optInt(JSON_METADATA_SDK_INT, 0),
|
androidVersion = meta.getInt(JSON_METADATA_SDK_INT),
|
||||||
androidIncremental = meta.optString(JSON_METADATA_INCREMENTAL),
|
androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL),
|
||||||
deviceName = meta.optString(JSON_METADATA_NAME),
|
deviceName = meta.getString(JSON_METADATA_NAME),
|
||||||
d2dBackup = meta.optBoolean(JSON_METADATA_D2D_BACKUP, false),
|
d2dBackup = meta.optBoolean(JSON_METADATA_D2D_BACKUP, false),
|
||||||
packageMetadataMap = packageMetadataMap,
|
packageMetadataMap = packageMetadataMap,
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,18 +6,42 @@
|
||||||
package com.stevesoltys.seedvault.metadata
|
package com.stevesoltys.seedvault.metadata
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.Utf8
|
import com.stevesoltys.seedvault.Utf8
|
||||||
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
interface MetadataWriter {
|
interface MetadataWriter {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun write(metadata: BackupMetadata, outputStream: OutputStream)
|
||||||
|
|
||||||
fun encode(metadata: BackupMetadata): ByteArray
|
fun encode(metadata: BackupMetadata): ByteArray
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class MetadataWriterImpl : MetadataWriter {
|
internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun write(metadata: BackupMetadata, outputStream: OutputStream) {
|
||||||
|
outputStream.write(ByteArray(1).apply { this[0] = metadata.version })
|
||||||
|
crypto.newEncryptingStream(outputStream, getAD(metadata.version, metadata.token)).use {
|
||||||
|
it.write(encode(metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun encode(metadata: BackupMetadata): ByteArray {
|
override fun encode(metadata: BackupMetadata): ByteArray {
|
||||||
val json = JSONObject().apply {
|
val json = JSONObject().apply {
|
||||||
put(JSON_METADATA, JSONObject())
|
put(JSON_METADATA, JSONObject().apply {
|
||||||
|
put(JSON_METADATA_VERSION, metadata.version.toInt())
|
||||||
|
put(JSON_METADATA_TOKEN, metadata.token)
|
||||||
|
put(JSON_METADATA_SALT, metadata.salt)
|
||||||
|
put(JSON_METADATA_TIME, metadata.time)
|
||||||
|
put(JSON_METADATA_SDK_INT, metadata.androidVersion)
|
||||||
|
put(JSON_METADATA_INCREMENTAL, metadata.androidIncremental)
|
||||||
|
put(JSON_METADATA_NAME, metadata.deviceName)
|
||||||
|
put(JSON_METADATA_D2D_BACKUP, metadata.d2dBackup)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
for ((packageName, packageMetadata) in metadata.packageMetadataMap) {
|
for ((packageName, packageMetadata) in metadata.packageMetadataMap) {
|
||||||
json.put(packageName, JSONObject().apply {
|
json.put(packageName, JSONObject().apply {
|
||||||
|
@ -33,8 +57,31 @@ internal class MetadataWriterImpl : MetadataWriter {
|
||||||
if (packageMetadata.size != null) {
|
if (packageMetadata.size != null) {
|
||||||
put(JSON_PACKAGE_SIZE, packageMetadata.size)
|
put(JSON_PACKAGE_SIZE, packageMetadata.size)
|
||||||
}
|
}
|
||||||
|
if (packageMetadata.name != null) {
|
||||||
|
put(JSON_PACKAGE_APP_NAME, packageMetadata.name)
|
||||||
|
}
|
||||||
|
if (packageMetadata.system) {
|
||||||
|
put(JSON_PACKAGE_SYSTEM, true)
|
||||||
|
}
|
||||||
|
if (packageMetadata.isLaunchableSystemApp) {
|
||||||
|
put(JSON_PACKAGE_SYSTEM_LAUNCHER, true)
|
||||||
|
}
|
||||||
|
packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) }
|
||||||
|
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }
|
||||||
|
packageMetadata.splits?.let { splits ->
|
||||||
|
put(JSON_PACKAGE_SPLITS, JSONArray().apply {
|
||||||
|
for (split in splits) put(JSONObject().apply {
|
||||||
|
put(JSON_PACKAGE_SPLIT_NAME, split.name)
|
||||||
|
if (split.size != null) put(JSON_PACKAGE_SIZE, split.size)
|
||||||
|
put(JSON_PACKAGE_SHA256, split.sha256)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
packageMetadata.sha256?.let { put(JSON_PACKAGE_SHA256, it) }
|
||||||
|
packageMetadata.signatures?.let { put(JSON_PACKAGE_SIGNATURES, JSONArray(it)) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return json.toString().toByteArray(Utf8)
|
return json.toString().toByteArray(Utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend
|
package com.stevesoltys.seedvault.plugins
|
||||||
|
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import java.io.IOException
|
import java.io.IOException
|
|
@ -0,0 +1,89 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins
|
||||||
|
|
||||||
|
import android.app.backup.RestoreSet
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
interface StoragePlugin<T> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the plugin is working, or false if it isn't.
|
||||||
|
* @throws Exception any kind of exception to provide more info on the error
|
||||||
|
*/
|
||||||
|
suspend fun test(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the available storage space in bytes.
|
||||||
|
* @return the number of bytes available or null if the number is unknown.
|
||||||
|
* Returning a negative number or zero to indicate unknown is discouraged.
|
||||||
|
*/
|
||||||
|
suspend fun getFreeSpace(): Long?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new [RestoreSet] with the given token.
|
||||||
|
*
|
||||||
|
* This is typically followed by a call to [initializeDevice].
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun startNewRestoreSet(token: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the storage for this device, erasing all stored data in the current [RestoreSet].
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun initializeDevice()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if there is data stored for the given name.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun hasData(token: Long, name: String): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a raw byte stream for writing data for the given name.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun getOutputStream(token: Long, name: String): OutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a raw byte stream with data for the given name.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun getInputStream(token: Long, name: String): InputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all data associated with the given name.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun removeData(token: Long, name: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the set of all backups currently available for restore.
|
||||||
|
*
|
||||||
|
* @return metadata for the set of restore images available,
|
||||||
|
* or null if an error occurred (the attempt should be rescheduled).
|
||||||
|
**/
|
||||||
|
suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the package name of the app that provides the backend storage
|
||||||
|
* which is used for the current backup location.
|
||||||
|
*
|
||||||
|
* Plugins are advised to cache this as it will be requested frequently.
|
||||||
|
*
|
||||||
|
* @return null if no package name could be found
|
||||||
|
*/
|
||||||
|
val providerPackageName: String?
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class EncryptedMetadata(val token: Long, val inputStreamRetriever: suspend () -> InputStream)
|
||||||
|
|
||||||
|
internal val tokenRegex = Regex("([0-9]{13})") // good until the year 2286
|
||||||
|
internal val chunkFolderRegex = Regex("[a-f0-9]{2}")
|
|
@ -3,74 +3,80 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend
|
package com.stevesoltys.seedvault.plugins
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.seedvault.getStorageContext
|
import com.stevesoltys.seedvault.getStorageContext
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
import com.stevesoltys.seedvault.repo.BlobCache
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.SafFactory
|
||||||
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavFactory
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.settings.StoragePluginType
|
import com.stevesoltys.seedvault.settings.StoragePluginType
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
|
||||||
import org.calyxos.seedvault.core.backends.BackendFactory
|
|
||||||
import org.calyxos.seedvault.core.backends.BackendProperties
|
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
|
||||||
|
|
||||||
class BackendManager(
|
class StoragePluginManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val blobCache: BlobCache,
|
safFactory: SafFactory,
|
||||||
backendFactory: BackendFactory,
|
webDavFactory: WebDavFactory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Volatile
|
private var mAppPlugin: StoragePlugin<*>?
|
||||||
private var mBackend: Backend?
|
private var mFilesPlugin: org.calyxos.backup.storage.api.StoragePlugin?
|
||||||
|
private var mStorageProperties: StorageProperties<*>?
|
||||||
|
|
||||||
@Volatile
|
val appPlugin: StoragePlugin<*>
|
||||||
private var mBackendProperties: BackendProperties<*>?
|
|
||||||
|
|
||||||
val backend: Backend
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
get() {
|
get() {
|
||||||
return mBackend ?: error("App plugin was loaded, but still null")
|
return mAppPlugin ?: error("App plugin was loaded, but still null")
|
||||||
}
|
}
|
||||||
|
|
||||||
val backendProperties: BackendProperties<*>?
|
val filesPlugin: org.calyxos.backup.storage.api.StoragePlugin
|
||||||
@Synchronized
|
@Synchronized
|
||||||
get() {
|
get() {
|
||||||
return mBackendProperties
|
return mFilesPlugin ?: error("Files plugin was loaded, but still null")
|
||||||
}
|
}
|
||||||
val isOnRemovableDrive: Boolean get() = backendProperties?.isUsb == true
|
|
||||||
val requiresNetwork: Boolean get() = backendProperties?.requiresNetwork == true
|
val storageProperties: StorageProperties<*>?
|
||||||
|
@Synchronized
|
||||||
|
get() {
|
||||||
|
return mStorageProperties
|
||||||
|
}
|
||||||
|
val isOnRemovableDrive: Boolean get() = storageProperties?.isUsb == true
|
||||||
|
|
||||||
init {
|
init {
|
||||||
when (settingsManager.storagePluginType) {
|
when (settingsManager.storagePluginType) {
|
||||||
StoragePluginType.SAF -> {
|
StoragePluginType.SAF -> {
|
||||||
val safConfig = settingsManager.getSafProperties() ?: error("No SAF storage saved")
|
val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage saved")
|
||||||
mBackend = backendFactory.createSafBackend(safConfig)
|
val documentsStorage = DocumentsStorage(context, settingsManager, safStorage)
|
||||||
mBackendProperties = safConfig
|
mAppPlugin = safFactory.createAppStoragePlugin(safStorage, documentsStorage)
|
||||||
|
mFilesPlugin = safFactory.createFilesStoragePlugin(safStorage, documentsStorage)
|
||||||
|
mStorageProperties = safStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
StoragePluginType.WEB_DAV -> {
|
StoragePluginType.WEB_DAV -> {
|
||||||
val webDavProperties =
|
val webDavProperties =
|
||||||
settingsManager.webDavProperties ?: error("No WebDAV config saved")
|
settingsManager.webDavProperties ?: error("No WebDAV config saved")
|
||||||
mBackend = backendFactory.createWebDavBackend(webDavProperties.config)
|
mAppPlugin = webDavFactory.createAppStoragePlugin(webDavProperties.config)
|
||||||
mBackendProperties = webDavProperties
|
mFilesPlugin = webDavFactory.createFilesStoragePlugin(webDavProperties.config)
|
||||||
|
mStorageProperties = webDavProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
mBackend = null
|
mAppPlugin = null
|
||||||
mBackendProperties = null
|
mFilesPlugin = null
|
||||||
|
mStorageProperties = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isValidAppPluginSet(): Boolean {
|
fun isValidAppPluginSet(): Boolean {
|
||||||
if (mBackend == null) return false
|
if (mAppPlugin == null || mFilesPlugin == null) return false
|
||||||
if (mBackend is SafBackend) {
|
if (mAppPlugin is DocumentsProviderStoragePlugin) {
|
||||||
val storage = settingsManager.getSafProperties() ?: return false
|
val storage = settingsManager.getSafStorage() ?: return false
|
||||||
if (storage.isUsb) return true
|
if (storage.isUsb) return true
|
||||||
return permitDiskReads {
|
return permitDiskReads {
|
||||||
storage.getDocumentFile(context).isDirectory
|
storage.getDocumentFile(context).isDirectory
|
||||||
|
@ -80,22 +86,20 @@ class BackendManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Changes the storage plugins and current [BackendProperties].
|
* Changes the storage plugins and current [StorageProperties].
|
||||||
*
|
*
|
||||||
* IMPORTANT: Do no call this while current plugins are being used,
|
* IMPORTANT: Do no call this while current plugins are being used,
|
||||||
* e.g. while backup/restore operation is still running.
|
* e.g. while backup/restore operation is still running.
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
|
||||||
@Synchronized
|
|
||||||
fun <T> changePlugins(
|
fun <T> changePlugins(
|
||||||
backend: Backend,
|
storageProperties: StorageProperties<T>,
|
||||||
storageProperties: BackendProperties<T>,
|
appPlugin: StoragePlugin<T>,
|
||||||
|
filesPlugin: org.calyxos.backup.storage.api.StoragePlugin,
|
||||||
) {
|
) {
|
||||||
settingsManager.setStorageBackend(backend)
|
settingsManager.setStoragePlugin(appPlugin)
|
||||||
mBackend = backend
|
mStorageProperties = storageProperties
|
||||||
mBackendProperties = storageProperties
|
mAppPlugin = appPlugin
|
||||||
blobCache.clearLocalCache()
|
mFilesPlugin = filesPlugin
|
||||||
// TODO not critical, but nice to have: clear also local snapshot cache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -108,7 +112,7 @@ class BackendManager(
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun canDoBackupNow(): Boolean {
|
fun canDoBackupNow(): Boolean {
|
||||||
val storage = backendProperties ?: return false
|
val storage = storageProperties ?: return false
|
||||||
return !isOnUnavailableUsb() &&
|
return !isOnUnavailableUsb() &&
|
||||||
!storage.isUnavailableNetwork(context, settingsManager.useMeteredNetwork)
|
!storage.isUnavailableNetwork(context, settingsManager.useMeteredNetwork)
|
||||||
}
|
}
|
||||||
|
@ -123,7 +127,7 @@ class BackendManager(
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun isOnUnavailableUsb(): Boolean {
|
fun isOnUnavailableUsb(): Boolean {
|
||||||
val storage = backendProperties ?: return false
|
val storage = storageProperties ?: return false
|
||||||
val systemContext = context.getStorageContext { storage.isUsb }
|
val systemContext = context.getStorageContext { storage.isUsb }
|
||||||
return storage.isUnavailableUsb(systemContext)
|
return storage.isUnavailableUsb(systemContext)
|
||||||
}
|
}
|
||||||
|
@ -134,7 +138,7 @@ class BackendManager(
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
suspend fun getFreeSpace(): Long? {
|
suspend fun getFreeSpace(): Long? {
|
||||||
return try {
|
return try {
|
||||||
backend.getFreeSpace()
|
appPlugin.getFreeSpace()
|
||||||
} catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm
|
} catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm
|
||||||
Log.e("StoragePluginManager", "Error getting free space: ", e)
|
Log.e("StoragePluginManager", "Error getting free space: ", e)
|
||||||
null
|
null
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.calyxos.seedvault.core.backends
|
package com.stevesoltys.seedvault.plugins
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
|
@ -12,20 +12,20 @@ import androidx.annotation.WorkerThread
|
||||||
import at.bitfire.dav4jvm.exception.HttpException
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
public abstract class BackendProperties<T> {
|
abstract class StorageProperties<T> {
|
||||||
public abstract val config: T
|
abstract val config: T
|
||||||
public abstract val name: String
|
abstract val name: String
|
||||||
public abstract val isUsb: Boolean
|
abstract val isUsb: Boolean
|
||||||
public abstract val requiresNetwork: Boolean
|
abstract val requiresNetwork: Boolean
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
public abstract fun isUnavailableUsb(context: Context): Boolean
|
abstract fun isUnavailableUsb(context: Context): Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if this is storage that requires network access,
|
* Returns true if this is storage that requires network access,
|
||||||
* but it isn't available right now.
|
* but it isn't available right now.
|
||||||
*/
|
*/
|
||||||
public fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean {
|
fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean {
|
||||||
return requiresNetwork && !hasUnmeteredInternet(context, allowMetered)
|
return requiresNetwork && !hasUnmeteredInternet(context, allowMetered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ public abstract class BackendProperties<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun Exception.isOutOfSpace(): Boolean {
|
fun Exception.isOutOfSpace(): Boolean {
|
||||||
return when (this) {
|
return when (this) {
|
||||||
is IOException -> message?.contains("No space left on device") == true ||
|
is IOException -> message?.contains("No space left on device") == true ||
|
||||||
(cause as? HttpException)?.code == 507
|
(cause as? HttpException)?.code == 507
|
|
@ -3,13 +3,13 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
|
@ -3,14 +3,15 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val storagePluginModuleSaf = module {
|
val storagePluginModuleSaf = module {
|
||||||
|
single { SafFactory(androidContext(), get(), get()) }
|
||||||
single { SafHandler(androidContext(), get(), get(), get()) }
|
single { SafHandler(androidContext(), get(), get(), get()) }
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
|
@ -18,9 +19,8 @@ val storagePluginModuleSaf = module {
|
||||||
DocumentsProviderLegacyPlugin(
|
DocumentsProviderLegacyPlugin(
|
||||||
context = androidContext(),
|
context = androidContext(),
|
||||||
storageGetter = {
|
storageGetter = {
|
||||||
val safProperties = get<SettingsManager>().getSafProperties()
|
val safStorage = get<SettingsManager>().getSafStorage() ?: error("No SAF storage")
|
||||||
?: error("No SAF storage")
|
DocumentsStorage(androidContext(), get(), safStorage)
|
||||||
DocumentsStorage(androidContext(), safProperties)
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -0,0 +1,193 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.os.StatFs
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
|
||||||
|
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.database.getIntOrNull
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.stevesoltys.seedvault.getStorageContext
|
||||||
|
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
|
||||||
|
import com.stevesoltys.seedvault.plugins.tokenRegex
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
|
||||||
|
import com.stevesoltys.seedvault.ui.storage.ROOT_ID_DEVICE
|
||||||
|
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
|
||||||
|
|
||||||
|
internal class DocumentsProviderStoragePlugin(
|
||||||
|
private val appContext: Context,
|
||||||
|
private val storage: DocumentsStorage,
|
||||||
|
) : StoragePlugin<Uri> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attention: This context might be from a different user. Use with care.
|
||||||
|
*/
|
||||||
|
private val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
|
||||||
|
|
||||||
|
private val packageManager: PackageManager = appContext.packageManager
|
||||||
|
|
||||||
|
override suspend fun test(): Boolean {
|
||||||
|
val dir = storage.rootBackupDir
|
||||||
|
return dir != null && dir.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getFreeSpace(): Long? {
|
||||||
|
val rootId = storage.safStorage.rootId ?: return null
|
||||||
|
val authority = storage.safStorage.uri.authority
|
||||||
|
// using DocumentsContract#buildRootUri(String, String) with rootId directly doesn't work
|
||||||
|
val rootUri = DocumentsContract.buildRootsUri(authority)
|
||||||
|
val projection = arrayOf(COLUMN_AVAILABLE_BYTES)
|
||||||
|
// query directly for our rootId
|
||||||
|
val bytesAvailable = context.contentResolver.query(
|
||||||
|
rootUri, projection, "$COLUMN_ROOT_ID=?", arrayOf(rootId), null
|
||||||
|
)?.use { c ->
|
||||||
|
if (!c.moveToNext()) return@use null // no results
|
||||||
|
val bytes = c.getIntOrNull(c.getColumnIndex(COLUMN_AVAILABLE_BYTES))
|
||||||
|
if (bytes != null && bytes >= 0) return@use bytes.toLong()
|
||||||
|
else return@use null
|
||||||
|
}
|
||||||
|
// if we didn't get anything from SAF, try some known hacks
|
||||||
|
return if (bytesAvailable == null && authority == AUTHORITY_STORAGE) {
|
||||||
|
if (rootId == ROOT_ID_DEVICE) {
|
||||||
|
StatFs(Environment.getDataDirectory().absolutePath).availableBytes
|
||||||
|
} else if (storage.safStorage.isUsb) {
|
||||||
|
val documentId = storage.safStorage.uri.lastPathSegment ?: return null
|
||||||
|
StatFs("/mnt/media_rw/${documentId.trimEnd(':')}").availableBytes
|
||||||
|
} else null
|
||||||
|
} else bytesAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun startNewRestoreSet(token: Long) {
|
||||||
|
// reset current storage
|
||||||
|
storage.reset(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun initializeDevice() {
|
||||||
|
// reset storage without new token, so folders get recreated
|
||||||
|
// otherwise stale DocumentFiles will hang around
|
||||||
|
storage.reset(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun hasData(token: Long, name: String): Boolean {
|
||||||
|
val setDir = storage.getSetDir(token) ?: return false
|
||||||
|
return setDir.findFileBlocking(context, name) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun getOutputStream(token: Long, name: String): OutputStream {
|
||||||
|
val setDir = storage.getSetDir(token) ?: throw IOException()
|
||||||
|
val file = setDir.createOrGetFile(context, name)
|
||||||
|
return storage.getOutputStream(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun getInputStream(token: Long, name: String): InputStream {
|
||||||
|
val setDir = storage.getSetDir(token) ?: throw IOException()
|
||||||
|
val file = setDir.findFileBlocking(context, name) ?: throw FileNotFoundException()
|
||||||
|
return storage.getInputStream(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun removeData(token: Long, name: String) {
|
||||||
|
val setDir = storage.getSetDir(token) ?: throw IOException()
|
||||||
|
val file = setDir.findFileBlocking(context, name) ?: return
|
||||||
|
if (!file.delete()) throw IOException("Failed to delete $name")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
||||||
|
val rootDir = storage.rootBackupDir ?: return null
|
||||||
|
val backupSets = getBackups(context, rootDir)
|
||||||
|
val iterator = backupSets.iterator()
|
||||||
|
return generateSequence {
|
||||||
|
if (!iterator.hasNext()) return@generateSequence null // end sequence
|
||||||
|
val backupSet = iterator.next()
|
||||||
|
EncryptedMetadata(backupSet.token) {
|
||||||
|
storage.getInputStream(backupSet.metadataFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val providerPackageName: String? by lazy {
|
||||||
|
val authority = storage.getAuthority() ?: return@lazy null
|
||||||
|
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null
|
||||||
|
providerInfo.packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupSet(val token: Long, val metadataFile: DocumentFile)
|
||||||
|
|
||||||
|
internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
|
||||||
|
val backupSets = ArrayList<BackupSet>()
|
||||||
|
val files = try {
|
||||||
|
// block until the DocumentsProvider has results
|
||||||
|
rootDir.listFilesBlocking(context)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error loading backups from storage", e)
|
||||||
|
return backupSets
|
||||||
|
}
|
||||||
|
for (set in files) {
|
||||||
|
// retrieve name only once as this causes a DB query
|
||||||
|
val name = set.name
|
||||||
|
|
||||||
|
// get current token from set or continue to next file/set
|
||||||
|
val token = set.getTokenOrNull(name) ?: continue
|
||||||
|
|
||||||
|
// block until children of set are available
|
||||||
|
val metadata = try {
|
||||||
|
set.findFileBlocking(context, FILE_BACKUP_METADATA)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error reading metadata file in backup set folder: $name", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (metadata == null) {
|
||||||
|
Log.w(TAG, "Missing metadata file in backup set folder: $name")
|
||||||
|
} else {
|
||||||
|
backupSets.add(BackupSet(token, metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return backupSets
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DocumentFile.getTokenOrNull(name: String?): Long? {
|
||||||
|
val looksLikeToken = name != null && tokenRegex.matches(name)
|
||||||
|
// check for isDirectory only if we already have a valid token (causes DB query)
|
||||||
|
if (!looksLikeToken || !isDirectory) {
|
||||||
|
// only log unexpected output
|
||||||
|
if (name != null && isUnexpectedFile(name)) {
|
||||||
|
Log.w(TAG, "Found invalid backup set folder: $name")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
name?.toLong()
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
throw AssertionError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUnexpectedFile(name: String): Boolean {
|
||||||
|
return name != FILE_NO_MEDIA &&
|
||||||
|
!chunkFolderRegex.matches(name) &&
|
||||||
|
!name.endsWith(SNAPSHOT_EXT)
|
||||||
|
}
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
@ -20,28 +20,33 @@ import android.util.Log
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.getStorageContext
|
import com.stevesoltys.seedvault.getStorageContext
|
||||||
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT
|
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
|
||||||
import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
|
||||||
|
|
||||||
@Deprecated("")
|
@Deprecated("")
|
||||||
const val DIRECTORY_FULL_BACKUP = "full"
|
const val DIRECTORY_FULL_BACKUP = "full"
|
||||||
|
|
||||||
@Deprecated("")
|
@Deprecated("")
|
||||||
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
|
const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
|
||||||
|
const val FILE_BACKUP_METADATA = ".backup.metadata"
|
||||||
|
const val FILE_NO_MEDIA = ".nomedia"
|
||||||
|
const val MIME_TYPE = "application/octet-stream"
|
||||||
|
|
||||||
private val TAG = DocumentsStorage::class.java.simpleName
|
private val TAG = DocumentsStorage::class.java.simpleName
|
||||||
|
|
||||||
internal class DocumentsStorage(
|
internal class DocumentsStorage(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
internal val safStorage: SafProperties,
|
private val settingsManager: SettingsManager,
|
||||||
|
internal val safStorage: SafStorage,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -50,12 +55,16 @@ internal class DocumentsStorage(
|
||||||
private val context: Context get() = appContext.getStorageContext { safStorage.isUsb }
|
private val context: Context get() = appContext.getStorageContext { safStorage.isUsb }
|
||||||
private val contentResolver: ContentResolver get() = context.contentResolver
|
private val contentResolver: ContentResolver get() = context.contentResolver
|
||||||
|
|
||||||
private var rootBackupDir: DocumentFile? = null
|
internal var rootBackupDir: DocumentFile? = null
|
||||||
get() = runBlocking {
|
get() = runBlocking {
|
||||||
if (field == null) {
|
if (field == null) {
|
||||||
val parent = safStorage.getDocumentFile(context)
|
val parent = safStorage.getDocumentFile(context)
|
||||||
field = try {
|
field = try {
|
||||||
parent.createOrGetDirectory(context, DIRECTORY_ROOT)
|
parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
|
||||||
|
// create .nomedia file to prevent Android's MediaScanner
|
||||||
|
// from trying to index the backup
|
||||||
|
createOrGetFile(context, FILE_NO_MEDIA)
|
||||||
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error creating root backup dir.", e)
|
Log.e(TAG, "Error creating root backup dir.", e)
|
||||||
null
|
null
|
||||||
|
@ -64,8 +73,41 @@ internal class DocumentsStorage(
|
||||||
field
|
field
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentToken: Long? = null
|
||||||
|
get() {
|
||||||
|
if (field == null) field = settingsManager.getToken()
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSetDir: DocumentFile? = null
|
||||||
|
get() = runBlocking {
|
||||||
|
if (field == null) {
|
||||||
|
if (currentToken == 0L) return@runBlocking null
|
||||||
|
field = try {
|
||||||
|
rootBackupDir?.createOrGetDirectory(context, currentToken.toString())
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error creating current restore set dir.", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
field
|
||||||
|
}
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets this storage abstraction, forcing it to re-fetch cached values on next access.
|
||||||
|
*/
|
||||||
|
fun reset(newToken: Long?) {
|
||||||
|
currentToken = newToken
|
||||||
|
rootBackupDir = null
|
||||||
|
currentSetDir = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAuthority(): String? = safStorage.uri.authority
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
suspend fun getSetDir(token: Long): DocumentFile? {
|
suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
|
||||||
|
if (token == currentToken) return currentSetDir
|
||||||
return rootBackupDir?.findFileBlocking(context, token.toString())
|
return rootBackupDir?.findFileBlocking(context, token.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,6 +135,43 @@ internal class DocumentsStorage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun getOutputStream(file: DocumentFile): OutputStream {
|
||||||
|
return try {
|
||||||
|
contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// SAF can throw all sorts of exceptions, so wrap it in IOException
|
||||||
|
throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file exists and if not, creates it.
|
||||||
|
*
|
||||||
|
* If we were trying to create it right away, some providers create "filename (1)".
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
internal suspend fun DocumentFile.createOrGetFile(
|
||||||
|
context: Context,
|
||||||
|
name: String,
|
||||||
|
mimeType: String = MIME_TYPE,
|
||||||
|
): DocumentFile {
|
||||||
|
return try {
|
||||||
|
findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
|
||||||
|
if (this.name != name) {
|
||||||
|
throw IOException("File named ${this.name}, but should be $name")
|
||||||
|
}
|
||||||
|
} ?: throw IOException("could not find nor create")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// SAF can throw all sorts of exceptions, so wrap it in IOException.
|
||||||
|
// E.g. IllegalArgumentException can be thrown by FileSystemProvider#isChildDocument()
|
||||||
|
// when flash drive is not plugged-in:
|
||||||
|
// http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -107,6 +186,11 @@ suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): D
|
||||||
} ?: throw IOException()
|
} ?: throw IOException()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun DocumentFile.deleteContents(context: Context) {
|
||||||
|
for (file in listFilesBlocking(context)) file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
|
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
|
||||||
if (name != packageInfo.packageName) {
|
if (name != packageInfo.packageName) {
|
||||||
throw AssertionError("Expected ${packageInfo.packageName}, but got $name")
|
throw AssertionError("Expected ${packageInfo.packageName}, but got $name")
|
||||||
|
@ -140,6 +224,26 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile>
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent.
|
||||||
|
*
|
||||||
|
* All other public ways to get a TreeDocumentFile only work from [Uri]s
|
||||||
|
* (e.g. [DocumentFile.fromTreeUri]) and always set parent to null.
|
||||||
|
*
|
||||||
|
* We have a test for this method to ensure CI will alert us when this reflection breaks.
|
||||||
|
* Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails.
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
internal fun getTreeDocumentFile(parent: DocumentFile, context: Context, uri: Uri): DocumentFile {
|
||||||
|
@SuppressWarnings("MagicNumber")
|
||||||
|
val constructor = parent.javaClass.declaredConstructors.find {
|
||||||
|
it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3
|
||||||
|
}
|
||||||
|
check(constructor != null) { "Could not find constructor for TreeDocumentFile" }
|
||||||
|
constructor.isAccessible = true
|
||||||
|
return constructor.newInstance(parent, context, uri) as DocumentFile
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as [DocumentFile.findFile] only that it re-queries when the first result was stale.
|
* Same as [DocumentFile.findFile] only that it re-queries when the first result was stale.
|
||||||
*
|
*
|
||||||
|
@ -181,7 +285,7 @@ suspend fun DocumentFile.findFileBlocking(context: Context, displayName: String)
|
||||||
@Throws(IOException::class, TimeoutCancellationException::class)
|
@Throws(IOException::class, TimeoutCancellationException::class)
|
||||||
internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) =
|
internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) =
|
||||||
withTimeout(timeout) {
|
withTimeout(timeout) {
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine<Cursor> { cont ->
|
||||||
val cursor = query() ?: throw IOException()
|
val cursor = query() ?: throw IOException()
|
||||||
cont.invokeOnCancellation { cursor.close() }
|
cont.invokeOnCancellation { cursor.close() }
|
||||||
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
|
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.storage.SeedvaultSafStoragePlugin
|
||||||
|
|
||||||
|
class SafFactory(
|
||||||
|
private val context: Context,
|
||||||
|
private val keyManager: KeyManager,
|
||||||
|
private val settingsManager: SettingsManager,
|
||||||
|
) {
|
||||||
|
|
||||||
|
internal fun createAppStoragePlugin(
|
||||||
|
safStorage: SafStorage,
|
||||||
|
documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage),
|
||||||
|
): StoragePlugin<Uri> {
|
||||||
|
return DocumentsProviderStoragePlugin(context, documentsStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun createFilesStoragePlugin(
|
||||||
|
safStorage: SafStorage,
|
||||||
|
documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage),
|
||||||
|
): org.calyxos.backup.storage.api.StoragePlugin {
|
||||||
|
return SeedvaultSafStoragePlugin(context, documentsStorage, keyManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Context.USB_SERVICE
|
import android.content.Context.USB_SERVICE
|
||||||
|
@ -14,41 +14,33 @@ import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.isMassStorage
|
import com.stevesoltys.seedvault.isMassStorage
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.settings.FlashDrive
|
import com.stevesoltys.seedvault.settings.FlashDrive
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.ui.storage.StorageOption
|
import com.stevesoltys.seedvault.ui.storage.StorageOption
|
||||||
import org.calyxos.seedvault.core.backends.BackendFactory
|
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
private const val TAG = "SafHandler"
|
private const val TAG = "SafHandler"
|
||||||
|
|
||||||
internal class SafHandler(
|
internal class SafHandler(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val backendFactory: BackendFactory,
|
private val safFactory: SafFactory,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val backendManager: BackendManager,
|
private val storagePluginManager: StoragePluginManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun onConfigReceived(uri: Uri, safOption: StorageOption.SafOption): SafProperties {
|
fun onConfigReceived(uri: Uri, safOption: StorageOption.SafOption): SafStorage {
|
||||||
// persist permission to access backup folder across reboots
|
// persist permission to access backup folder across reboots
|
||||||
val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
|
val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||||
|
|
||||||
return SafProperties(
|
val name = if (safOption.isInternal()) {
|
||||||
config = uri,
|
"${safOption.title} (${context.getString(R.string.settings_backup_location_internal)})"
|
||||||
name = if (safOption.isInternal()) {
|
} else {
|
||||||
val brackets = context.getString(R.string.settings_backup_location_internal)
|
safOption.title
|
||||||
"${safOption.title} ($brackets)"
|
}
|
||||||
} else {
|
return SafStorage(uri, name, safOption.isUsb, safOption.requiresNetwork, safOption.rootId)
|
||||||
safOption.title
|
|
||||||
},
|
|
||||||
isUsb = safOption.isUsb,
|
|
||||||
requiresNetwork = safOption.requiresNetwork,
|
|
||||||
rootId = safOption.rootId,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -57,15 +49,17 @@ internal class SafHandler(
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
suspend fun hasAppBackup(safProperties: SafProperties): Boolean {
|
suspend fun hasAppBackup(safStorage: SafStorage): Boolean {
|
||||||
val backend = backendFactory.createSafBackend(safProperties)
|
val storage = DocumentsStorage(context, settingsManager, safStorage)
|
||||||
return backend.getAvailableBackupFileHandles().isNotEmpty()
|
val appPlugin = safFactory.createAppStoragePlugin(safStorage, storage)
|
||||||
|
val backups = appPlugin.getAvailableBackups()
|
||||||
|
return backups != null && backups.iterator().hasNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(safProperties: SafProperties) {
|
fun save(safStorage: SafStorage) {
|
||||||
settingsManager.setSafProperties(safProperties)
|
settingsManager.setSafStorage(safStorage)
|
||||||
|
|
||||||
if (safProperties.isUsb) {
|
if (safStorage.isUsb) {
|
||||||
Log.d(TAG, "Selected storage is a removable USB device.")
|
Log.d(TAG, "Selected storage is a removable USB device.")
|
||||||
val wasSaved = saveUsbDevice()
|
val wasSaved = saveUsbDevice()
|
||||||
// reset stored flash drive, if we did not update it
|
// reset stored flash drive, if we did not update it
|
||||||
|
@ -73,7 +67,7 @@ internal class SafHandler(
|
||||||
} else {
|
} else {
|
||||||
settingsManager.setFlashDrive(null)
|
settingsManager.setFlashDrive(null)
|
||||||
}
|
}
|
||||||
Log.d(TAG, "New storage location saved: ${safProperties.uri}")
|
Log.d(TAG, "New storage location saved: ${safStorage.uri}")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveUsbDevice(): Boolean {
|
private fun saveUsbDevice(): Boolean {
|
||||||
|
@ -90,11 +84,12 @@ internal class SafHandler(
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
fun setPlugin(safStorage: SafStorage) {
|
||||||
fun setPlugin(safProperties: SafProperties) {
|
val storage = DocumentsStorage(context, settingsManager, safStorage)
|
||||||
backendManager.changePlugins(
|
storagePluginManager.changePlugins(
|
||||||
backend = backendFactory.createSafBackend(safProperties),
|
storageProperties = safStorage,
|
||||||
storageProperties = safProperties,
|
appPlugin = safFactory.createAppStoragePlugin(safStorage, storage),
|
||||||
|
filesPlugin = safFactory.createFilesStoragePlugin(safStorage, storage),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -3,16 +3,16 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.calyxos.seedvault.core.backends.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
|
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import org.calyxos.seedvault.core.backends.BackendProperties
|
import com.stevesoltys.seedvault.plugins.StorageProperties
|
||||||
|
|
||||||
public data class SafProperties(
|
data class SafStorage(
|
||||||
override val config: Uri,
|
override val config: Uri,
|
||||||
override val name: String,
|
override val name: String,
|
||||||
override val isUsb: Boolean,
|
override val isUsb: Boolean,
|
||||||
|
@ -22,13 +22,12 @@ public data class SafProperties(
|
||||||
* This is only nullable for historic reasons, because we didn't always store it.
|
* This is only nullable for historic reasons, because we didn't always store it.
|
||||||
*/
|
*/
|
||||||
val rootId: String?,
|
val rootId: String?,
|
||||||
) : BackendProperties<Uri>() {
|
) : StorageProperties<Uri>() {
|
||||||
|
|
||||||
public val uri: Uri = config
|
val uri: Uri = config
|
||||||
|
|
||||||
public fun getDocumentFile(context: Context): DocumentFile =
|
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, config)
|
||||||
DocumentFile.fromTreeUri(context, config)
|
?: throw AssertionError("Should only happen on API < 21.")
|
||||||
?: throw AssertionError("Should only happen on API < 21.")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if this is USB storage that is not available, false otherwise.
|
* Returns true if this is USB storage that is not available, false otherwise.
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
@ -14,7 +14,7 @@ import android.provider.DocumentsContract
|
||||||
import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
||||||
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.backend.saf.StorageRootResolver.getIcon
|
import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver.getIcon
|
||||||
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5
|
||||||
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD
|
||||||
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC
|
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.Manifest.permission.MANAGE_DOCUMENTS
|
import android.Manifest.permission.MANAGE_DOCUMENTS
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
@ -139,6 +139,17 @@ internal object StorageRootResolver {
|
||||||
return if (index != -1) getInt(index) else 0
|
return if (index != -1) getInt(index) else 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Cursor.getLong(columnName: String): Long? {
|
||||||
|
val index = getColumnIndex(columnName)
|
||||||
|
if (index == -1) return null
|
||||||
|
val value = getString(index) ?: return null
|
||||||
|
return try {
|
||||||
|
java.lang.Long.parseLong(value)
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
|
fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
|
||||||
return getPackageIcon(context, authority, icon) ?: when {
|
return getPackageIcon(context, authority, icon) ?: when {
|
||||||
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
|
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
|
|
@ -3,9 +3,9 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.calyxos.seedvault.core.backends.webdav
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
public data class WebDavConfig(
|
data class WebDavConfig(
|
||||||
val url: String,
|
val url: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
val password: String,
|
val password: String,
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.Settings
|
||||||
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
|
||||||
|
class WebDavFactory(
|
||||||
|
private val context: Context,
|
||||||
|
private val keyManager: KeyManager,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin<WebDavConfig> {
|
||||||
|
return WebDavStoragePlugin(context, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createFilesStoragePlugin(
|
||||||
|
config: WebDavConfig,
|
||||||
|
): org.calyxos.backup.storage.api.StoragePlugin {
|
||||||
|
@SuppressLint("HardwareIds")
|
||||||
|
val androidId =
|
||||||
|
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
|
||||||
|
return com.stevesoltys.seedvault.storage.WebDavStoragePlugin(
|
||||||
|
keyManager = keyManager,
|
||||||
|
androidId = androidId,
|
||||||
|
webDavConfig = config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -3,20 +3,17 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend.webdav
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import org.calyxos.seedvault.core.backends.BackendFactory
|
|
||||||
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
|
||||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
internal sealed interface WebDavConfigState {
|
internal sealed interface WebDavConfigState {
|
||||||
|
@ -24,7 +21,7 @@ internal sealed interface WebDavConfigState {
|
||||||
object Checking : WebDavConfigState
|
object Checking : WebDavConfigState
|
||||||
class Success(
|
class Success(
|
||||||
val properties: WebDavProperties,
|
val properties: WebDavProperties,
|
||||||
val backend: Backend,
|
val plugin: WebDavStoragePlugin,
|
||||||
) : WebDavConfigState
|
) : WebDavConfigState
|
||||||
|
|
||||||
class Error(val e: Exception?) : WebDavConfigState
|
class Error(val e: Exception?) : WebDavConfigState
|
||||||
|
@ -34,14 +31,14 @@ private val TAG = WebDavHandler::class.java.simpleName
|
||||||
|
|
||||||
internal class WebDavHandler(
|
internal class WebDavHandler(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val backendFactory: BackendFactory,
|
private val webDavFactory: WebDavFactory,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val backendManager: BackendManager,
|
private val storagePluginManager: StoragePluginManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties {
|
fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties {
|
||||||
val host = config.url.removePrefix("https://")
|
val host = config.url.toHttpUrl().host
|
||||||
return WebDavProperties(
|
return WebDavProperties(
|
||||||
config = config,
|
config = config,
|
||||||
name = context.getString(R.string.storage_webdav_name, host),
|
name = context.getString(R.string.storage_webdav_name, host),
|
||||||
|
@ -54,11 +51,11 @@ internal class WebDavHandler(
|
||||||
|
|
||||||
suspend fun onConfigReceived(config: WebDavConfig) {
|
suspend fun onConfigReceived(config: WebDavConfig) {
|
||||||
mConfigState.value = WebDavConfigState.Checking
|
mConfigState.value = WebDavConfigState.Checking
|
||||||
val backend = backendFactory.createWebDavBackend(config)
|
val plugin = webDavFactory.createAppStoragePlugin(config) as WebDavStoragePlugin
|
||||||
try {
|
try {
|
||||||
if (backend.test()) {
|
if (plugin.test()) {
|
||||||
val properties = createWebDavProperties(context, config)
|
val properties = createWebDavProperties(context, config)
|
||||||
mConfigState.value = WebDavConfigState.Success(properties, backend)
|
mConfigState.value = WebDavConfigState.Success(properties, plugin)
|
||||||
} else {
|
} else {
|
||||||
mConfigState.value = WebDavConfigState.Error(null)
|
mConfigState.value = WebDavConfigState.Error(null)
|
||||||
}
|
}
|
||||||
|
@ -78,19 +75,20 @@ internal class WebDavHandler(
|
||||||
*/
|
*/
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
suspend fun hasAppBackup(backend: Backend): Boolean {
|
suspend fun hasAppBackup(appPlugin: WebDavStoragePlugin): Boolean {
|
||||||
return backend.getAvailableBackupFileHandles().isNotEmpty()
|
val backups = appPlugin.getAvailableBackups()
|
||||||
|
return backups != null && backups.iterator().hasNext()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(properties: WebDavProperties) {
|
fun save(properties: WebDavProperties) {
|
||||||
settingsManager.saveWebDavConfig(properties.config)
|
settingsManager.saveWebDavConfig(properties.config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
fun setPlugin(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
|
||||||
fun setPlugin(properties: WebDavProperties, backend: Backend) {
|
storagePluginManager.changePlugins(
|
||||||
backendManager.changePlugins(
|
|
||||||
backend = backend,
|
|
||||||
storageProperties = properties,
|
storageProperties = properties,
|
||||||
|
appPlugin = plugin,
|
||||||
|
filesPlugin = webDavFactory.createFilesStoragePlugin(properties.config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,12 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend.webdav
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val storagePluginModuleWebDav = module {
|
val storagePluginModuleWebDav = module {
|
||||||
|
single { WebDavFactory(androidContext(), get()) }
|
||||||
single { WebDavHandler(androidContext(), get(), get(), get()) }
|
single { WebDavHandler(androidContext(), get(), get(), get()) }
|
||||||
}
|
}
|
|
@ -3,15 +3,15 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.calyxos.seedvault.core.backends.webdav
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import org.calyxos.seedvault.core.backends.BackendProperties
|
import com.stevesoltys.seedvault.plugins.StorageProperties
|
||||||
|
|
||||||
public data class WebDavProperties(
|
data class WebDavProperties(
|
||||||
override val config: WebDavConfig,
|
override val config: WebDavConfig,
|
||||||
override val name: String,
|
override val name: String,
|
||||||
) : BackendProperties<WebDavConfig>() {
|
) : StorageProperties<WebDavConfig>() {
|
||||||
override val isUsb: Boolean = false
|
override val isUsb: Boolean = false
|
||||||
override val requiresNetwork: Boolean = true
|
override val requiresNetwork: Boolean = true
|
||||||
override fun isUnavailableUsb(context: Context): Boolean = false
|
override fun isUnavailableUsb(context: Context): Boolean = false
|
|
@ -0,0 +1,258 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||||
|
import at.bitfire.dav4jvm.DavCollection
|
||||||
|
import at.bitfire.dav4jvm.MultiResponseCallback
|
||||||
|
import at.bitfire.dav4jvm.Property
|
||||||
|
import at.bitfire.dav4jvm.PropertyFactory
|
||||||
|
import at.bitfire.dav4jvm.PropertyRegistry
|
||||||
|
import at.bitfire.dav4jvm.Response
|
||||||
|
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
|
||||||
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.NS_WEBDAV
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.ConnectionSpec
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okio.BufferedSink
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.io.PipedInputStream
|
||||||
|
import java.io.PipedOutputStream
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
const val DEBUG_LOG = true
|
||||||
|
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
|
||||||
|
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
internal abstract class WebDavStorage(
|
||||||
|
webDavConfig: WebDavConfig,
|
||||||
|
root: String = DIRECTORY_ROOT,
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val TAG: String = WebDavStorage::class.java.simpleName
|
||||||
|
}
|
||||||
|
|
||||||
|
private val authHandler = BasicDigestAuthHandler(
|
||||||
|
domain = null, // Optional, to only authenticate against hosts with this domain.
|
||||||
|
username = webDavConfig.username,
|
||||||
|
password = webDavConfig.password,
|
||||||
|
)
|
||||||
|
protected val okHttpClient = OkHttpClient.Builder()
|
||||||
|
.followRedirects(false)
|
||||||
|
.authenticator(authHandler)
|
||||||
|
.addNetworkInterceptor(authHandler)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(240, TimeUnit.SECONDS)
|
||||||
|
.pingInterval(45, TimeUnit.SECONDS)
|
||||||
|
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS))
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
protected val baseUrl = webDavConfig.url
|
||||||
|
protected val url = "${webDavConfig.url}/$root"
|
||||||
|
|
||||||
|
init {
|
||||||
|
PropertyRegistry.register(GetLastModified.Factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
protected suspend fun getOutputStream(location: HttpUrl): OutputStream {
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
val pipedInputStream = PipedInputStream()
|
||||||
|
val pipedOutputStream = PipedCloseActionOutputStream(pipedInputStream)
|
||||||
|
|
||||||
|
val body = object : RequestBody() {
|
||||||
|
override fun isOneShot(): Boolean = true
|
||||||
|
override fun contentType() = "application/octet-stream".toMediaType()
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
pipedInputStream.use { inputStream ->
|
||||||
|
sink.outputStream().use { outputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val deferred = GlobalScope.async(Dispatchers.IO) {
|
||||||
|
davCollection.put(body) { response ->
|
||||||
|
debugLog { "getOutputStream($location) = $response" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pipedOutputStream.doOnClose {
|
||||||
|
runBlocking { // blocking i/o wait
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pipedOutputStream
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
protected fun getInputStream(location: HttpUrl): InputStream {
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
val response = davCollection.get(accept = "", headers = null)
|
||||||
|
debugLog { "getInputStream($location) = $response" }
|
||||||
|
if (response.code / 100 != 2) throw IOException("HTTP error ${response.code}")
|
||||||
|
return response.body?.byteStream() ?: throw IOException()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to do [DavCollection.propfind] with a depth of `2` which is not in RFC4918.
|
||||||
|
* Since `infinity` isn't supported by nginx either,
|
||||||
|
* we fallback to iterating over all folders found with depth `1`
|
||||||
|
* and do another PROPFIND on those, passing the given [callback].
|
||||||
|
*/
|
||||||
|
protected fun DavCollection.propfindDepthTwo(callback: MultiResponseCallback) {
|
||||||
|
try {
|
||||||
|
propfind(
|
||||||
|
depth = 2, // this isn't defined in RFC4918
|
||||||
|
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
|
||||||
|
callback = callback,
|
||||||
|
)
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
if (e.isUnsupportedPropfind()) {
|
||||||
|
Log.i(TAG, "Got ${e.response}, trying two depth=1 PROPFINDs...")
|
||||||
|
propfindFakeTwo(callback)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DavCollection.propfindFakeTwo(callback: MultiResponseCallback) {
|
||||||
|
propfind(
|
||||||
|
depth = 1,
|
||||||
|
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
|
||||||
|
) { response, relation ->
|
||||||
|
debugLog { "propFindFakeTwo() = $response" }
|
||||||
|
// This callback will be called for everything in the folder
|
||||||
|
callback.onResponse(response, relation)
|
||||||
|
if (relation != SELF && response.isFolder()) {
|
||||||
|
DavCollection(okHttpClient, response.href).propfind(
|
||||||
|
depth = 1,
|
||||||
|
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
|
||||||
|
callback = callback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun HttpException.isUnsupportedPropfind(): Boolean {
|
||||||
|
// nginx returns 400 for depth=2
|
||||||
|
if (code == 400) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// lighttpd returns 403 with <DAV:propfind-finite-depth/> error as if we used infinity
|
||||||
|
if (code == 403 && responseBody?.contains("propfind-finite-depth") == true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
protected suspend fun DavCollection.createFolder(xmlBody: String? = null): okhttp3.Response {
|
||||||
|
return try {
|
||||||
|
suspendCoroutine { cont ->
|
||||||
|
mkCol(xmlBody) { response ->
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected inline fun debugLog(block: () -> String) {
|
||||||
|
if (DEBUG_LOG) Log.d(TAG, block())
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun Response.isFolder(): Boolean {
|
||||||
|
return this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PipedCloseActionOutputStream(
|
||||||
|
inputStream: PipedInputStream,
|
||||||
|
) : PipedOutputStream(inputStream) {
|
||||||
|
|
||||||
|
private var onClose: (() -> Unit)? = null
|
||||||
|
|
||||||
|
override fun write(b: Int) {
|
||||||
|
try {
|
||||||
|
super.write(b)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try {
|
||||||
|
onClose?.invoke()
|
||||||
|
} catch (closeException: Exception) {
|
||||||
|
e.addSuppressed(closeException)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun write(b: ByteArray?, off: Int, len: Int) {
|
||||||
|
try {
|
||||||
|
super.write(b, off, len)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try {
|
||||||
|
onClose?.invoke()
|
||||||
|
} catch (closeException: Exception) {
|
||||||
|
e.addSuppressed(closeException)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun close() {
|
||||||
|
super.close()
|
||||||
|
try {
|
||||||
|
onClose?.invoke()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun doOnClose(function: () -> Unit) {
|
||||||
|
this.onClose = function
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fake version of [at.bitfire.dav4jvm.property.webdav.GetLastModified] which we register
|
||||||
|
* so we don't need to depend on `org.apache.commons.lang3` which is used for date parsing.
|
||||||
|
*/
|
||||||
|
class GetLastModified : Property {
|
||||||
|
companion object {
|
||||||
|
@JvmField
|
||||||
|
val NAME = Property.Name(NS_WEBDAV, "getlastmodified")
|
||||||
|
}
|
||||||
|
|
||||||
|
object Factory : PropertyFactory {
|
||||||
|
override fun getName() = NAME
|
||||||
|
override fun create(parser: XmlPullParser): GetLastModified? = null
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,259 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.plugins.webdav
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import at.bitfire.dav4jvm.DavCollection
|
||||||
|
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
|
||||||
|
import at.bitfire.dav4jvm.exception.HttpException
|
||||||
|
import at.bitfire.dav4jvm.exception.NotFoundException
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.DisplayName
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.QuotaAvailableBytes
|
||||||
|
import at.bitfire.dav4jvm.property.webdav.ResourceType
|
||||||
|
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||||
|
import com.stevesoltys.seedvault.plugins.saf.FILE_NO_MEDIA
|
||||||
|
import com.stevesoltys.seedvault.plugins.tokenRegex
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
|
internal class WebDavStoragePlugin(
|
||||||
|
context: Context,
|
||||||
|
webDavConfig: WebDavConfig,
|
||||||
|
root: String = DIRECTORY_ROOT,
|
||||||
|
) : WebDavStorage(webDavConfig, root), StoragePlugin<WebDavConfig> {
|
||||||
|
|
||||||
|
override suspend fun test(): Boolean {
|
||||||
|
val location = (if (baseUrl.endsWith('/')) baseUrl else "$baseUrl/").toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
val webDavSupported = suspendCoroutine { cont ->
|
||||||
|
davCollection.options { davCapabilities, response ->
|
||||||
|
debugLog { "test() = $davCapabilities $response" }
|
||||||
|
if (davCapabilities.contains("1")) cont.resume(true)
|
||||||
|
else if (davCapabilities.contains("2")) cont.resume(true)
|
||||||
|
else if (davCapabilities.contains("3")) cont.resume(true)
|
||||||
|
else cont.resume(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return webDavSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getFreeSpace(): Long? {
|
||||||
|
val location = "$url/".toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
val availableBytes = suspendCoroutine { cont ->
|
||||||
|
davCollection.propfind(depth = 0, QuotaAvailableBytes.NAME) { response, _ ->
|
||||||
|
debugLog { "getFreeSpace() = $response" }
|
||||||
|
val quota = response.properties.getOrNull(0) as? QuotaAvailableBytes
|
||||||
|
val availableBytes = quota?.quotaAvailableBytes ?: -1
|
||||||
|
if (availableBytes > 0) {
|
||||||
|
cont.resume(availableBytes)
|
||||||
|
} else {
|
||||||
|
cont.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return availableBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun startNewRestoreSet(token: Long) {
|
||||||
|
val location = "$url/$token/".toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
val response = davCollection.createFolder()
|
||||||
|
debugLog { "startNewRestoreSet($token) = $response" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun initializeDevice() {
|
||||||
|
// TODO does it make sense to delete anything
|
||||||
|
// when [startNewRestoreSet] is always called first? Maybe unify both calls?
|
||||||
|
val location = "$url/".toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
try {
|
||||||
|
davCollection.head { response ->
|
||||||
|
debugLog { "Root exists: $response" }
|
||||||
|
}
|
||||||
|
} catch (e: NotFoundException) {
|
||||||
|
val response = davCollection.createFolder()
|
||||||
|
debugLog { "initializeDevice() = $response" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun hasData(token: Long, name: String): Boolean {
|
||||||
|
val location = "$url/$token/$name".toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val response = suspendCoroutine { cont ->
|
||||||
|
davCollection.head { response ->
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugLog { "hasData($token, $name) = $response" }
|
||||||
|
response.isSuccessful
|
||||||
|
} catch (e: NotFoundException) {
|
||||||
|
debugLog { "hasData($token, $name) = $e" }
|
||||||
|
false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun getOutputStream(token: Long, name: String): OutputStream {
|
||||||
|
val location = "$url/$token/$name".toHttpUrl()
|
||||||
|
return try {
|
||||||
|
getOutputStream(location)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException("Error getting OutputStream for $token and $name: ", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun getInputStream(token: Long, name: String): InputStream {
|
||||||
|
val location = "$url/$token/$name".toHttpUrl()
|
||||||
|
return try {
|
||||||
|
getInputStream(location)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException("Error getting InputStream for $token and $name: ", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override suspend fun removeData(token: Long, name: String) {
|
||||||
|
val location = "$url/$token/$name".toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = suspendCoroutine { cont ->
|
||||||
|
davCollection.delete { response ->
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debugLog { "removeData($token, $name) = $response" }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is IOException) throw e
|
||||||
|
else throw IOException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
||||||
|
return try {
|
||||||
|
doGetAvailableBackups()
|
||||||
|
} catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm
|
||||||
|
Log.e(TAG, "Error getting available backups: ", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun doGetAvailableBackups(): Sequence<EncryptedMetadata> {
|
||||||
|
val location = "$url/".toHttpUrl()
|
||||||
|
val davCollection = DavCollection(okHttpClient, location)
|
||||||
|
|
||||||
|
// get all restore set tokens in root folder
|
||||||
|
val tokens = ArrayList<Long>()
|
||||||
|
try {
|
||||||
|
davCollection.propfind(
|
||||||
|
depth = 2,
|
||||||
|
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
|
||||||
|
) { response, relation ->
|
||||||
|
debugLog { "getAvailableBackups() = $response" }
|
||||||
|
// This callback will be called for every file in the folder
|
||||||
|
if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2 &&
|
||||||
|
response.hrefName() == FILE_BACKUP_METADATA
|
||||||
|
) {
|
||||||
|
val tokenName = response.href.pathSegments[response.href.pathSegments.size - 2]
|
||||||
|
getTokenOrNull(tokenName)?.let { token ->
|
||||||
|
tokens.add(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
if (e.isUnsupportedPropfind()) getBackupTokenWithDepthOne(davCollection, tokens)
|
||||||
|
else throw e
|
||||||
|
}
|
||||||
|
val tokenIterator = tokens.iterator()
|
||||||
|
return generateSequence {
|
||||||
|
if (!tokenIterator.hasNext()) return@generateSequence null // end sequence
|
||||||
|
val token = tokenIterator.next()
|
||||||
|
EncryptedMetadata(token) {
|
||||||
|
getInputStream(token, FILE_BACKUP_METADATA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBackupTokenWithDepthOne(davCollection: DavCollection, tokens: ArrayList<Long>) {
|
||||||
|
davCollection.propfind(
|
||||||
|
depth = 1,
|
||||||
|
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
|
||||||
|
) { response, relation ->
|
||||||
|
debugLog { "getBackupTokenWithDepthOne() = $response" }
|
||||||
|
|
||||||
|
// we are only interested in sub-folders, skip rest
|
||||||
|
if (relation == SELF || !response.isFolder()) return@propfind
|
||||||
|
|
||||||
|
val token = getTokenOrNull(response.hrefName()) ?: return@propfind
|
||||||
|
val tokenUrl = response.href.newBuilder()
|
||||||
|
.addPathSegment(FILE_BACKUP_METADATA)
|
||||||
|
.build()
|
||||||
|
// check if .backup.metadata file exists using HEAD request,
|
||||||
|
// because some servers (e.g. nginx don't list hidden files with PROPFIND)
|
||||||
|
try {
|
||||||
|
DavCollection(okHttpClient, tokenUrl).head {
|
||||||
|
debugLog { "getBackupTokenWithDepthOne() = $response" }
|
||||||
|
tokens.add(token)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// just log exception and continue, we want to find all files that are there
|
||||||
|
Log.e(TAG, "Error retrieving $tokenUrl: ", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTokenOrNull(name: String): Long? {
|
||||||
|
val looksLikeToken = name.isNotEmpty() && tokenRegex.matches(name)
|
||||||
|
if (looksLikeToken) {
|
||||||
|
return try {
|
||||||
|
name.toLong()
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
throw AssertionError(e) // regex must be wrong
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isUnexpectedFile(name)) {
|
||||||
|
Log.w(TAG, "Found invalid backup set folder: $name")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUnexpectedFile(name: String): Boolean {
|
||||||
|
return name != FILE_NO_MEDIA &&
|
||||||
|
!chunkFolderRegex.matches(name) &&
|
||||||
|
!name.endsWith(SNAPSHOT_EXT)
|
||||||
|
}
|
||||||
|
|
||||||
|
override val providerPackageName: String = context.packageName // 100% built-in plugin
|
||||||
|
|
||||||
|
}
|
|
@ -1,191 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import com.stevesoltys.seedvault.MemoryLogger
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob
|
|
||||||
import org.calyxos.seedvault.core.backends.AppBackupFileType.Snapshot
|
|
||||||
import org.calyxos.seedvault.core.backends.FileInfo
|
|
||||||
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages the process of app data backups, especially related to work that needs to happen
|
|
||||||
* before and after a backup run.
|
|
||||||
* See [beforeBackup] and [afterBackupFinished].
|
|
||||||
*/
|
|
||||||
internal class AppBackupManager(
|
|
||||||
private val crypto: Crypto,
|
|
||||||
private val blobCache: BlobCache,
|
|
||||||
private val backendManager: BackendManager,
|
|
||||||
private val settingsManager: SettingsManager,
|
|
||||||
private val snapshotManager: SnapshotManager,
|
|
||||||
private val snapshotCreatorFactory: SnapshotCreatorFactory,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A temporary [SnapshotCreator] that has a lifetime only valid during the backup run.
|
|
||||||
*/
|
|
||||||
@Volatile
|
|
||||||
var snapshotCreator: SnapshotCreator? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var startedViaAdb = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this method before doing any kind of backup work.
|
|
||||||
* It will
|
|
||||||
* * download the blobs available on the backend,
|
|
||||||
* * assemble the chunk ID to blob mapping from previous snapshots and
|
|
||||||
* * create a new instance of a [SnapshotCreator].
|
|
||||||
*
|
|
||||||
* @throws IOException or other exceptions.
|
|
||||||
* These should be caught by the caller who may retry us on transient errors.
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun beforeBackup() {
|
|
||||||
log.info { "Loading existing snapshots and blobs..." }
|
|
||||||
val blobInfos = mutableListOf<FileInfo>()
|
|
||||||
val snapshotHandles = mutableListOf<Snapshot>()
|
|
||||||
backendManager.backend.list(
|
|
||||||
topLevelFolder = TopLevelFolder(crypto.repoId),
|
|
||||||
Blob::class, Snapshot::class,
|
|
||||||
) { fileInfo ->
|
|
||||||
when (fileInfo.fileHandle) {
|
|
||||||
is Blob -> blobInfos.add(fileInfo)
|
|
||||||
is Snapshot -> snapshotHandles.add(fileInfo.fileHandle as Snapshot)
|
|
||||||
else -> error("Unexpected FileHandle: $fileInfo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info { "Found ${snapshotHandles.size} existing snapshots." }
|
|
||||||
val snapshots = snapshotManager.onSnapshotsLoaded(snapshotHandles)
|
|
||||||
blobCache.populateCache(blobInfos, snapshots)
|
|
||||||
snapshotCreator = snapshotCreatorFactory.createSnapshotCreator()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This must be called after the backup run has been completed.
|
|
||||||
* It finalized the current snapshot and saves it to the backend.
|
|
||||||
* Then, it clears up the [BlobCache] and the [SnapshotCreator].
|
|
||||||
*
|
|
||||||
* @param success true if the backup run was successful, false otherwise.
|
|
||||||
*
|
|
||||||
* @return the snapshot saved to the backend or null if there was an error saving it.
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun afterBackupFinished(success: Boolean): com.stevesoltys.seedvault.proto.Snapshot? {
|
|
||||||
MemoryLogger.log()
|
|
||||||
log.info { "After backup finished. Success: $success" }
|
|
||||||
// free up memory by clearing blobs cache
|
|
||||||
blobCache.clear()
|
|
||||||
return try {
|
|
||||||
if (success) {
|
|
||||||
// only save snapshot when backup was successful,
|
|
||||||
// otherwise we'd have partial snapshots
|
|
||||||
val snapshot = snapshotCreator?.finalizeSnapshot()
|
|
||||||
?: error("Had no snapshotCreator")
|
|
||||||
keepTrying { // TODO remove when we have auto-retrying backends
|
|
||||||
// saving this is so important, we even keep trying
|
|
||||||
snapshotManager.saveSnapshot(snapshot)
|
|
||||||
}
|
|
||||||
// save token and time of last backup
|
|
||||||
settingsManager.onSuccessfulBackupCompleted(snapshot.token)
|
|
||||||
// after snapshot was written, we can clear local cache as its info is in snapshot
|
|
||||||
blobCache.clearLocalCache()
|
|
||||||
snapshot
|
|
||||||
} else null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error(e) { "Error finishing backup" }
|
|
||||||
null
|
|
||||||
} finally {
|
|
||||||
snapshotCreator = null
|
|
||||||
MemoryLogger.log()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When doing backups with `adb shell bmgr backupnow`,
|
|
||||||
* we don't get a chance to do our initialization in [beforeBackup],
|
|
||||||
* so we use this opportunity to do it now.
|
|
||||||
*/
|
|
||||||
suspend fun ensureBackupPrepared() = if (snapshotCreator == null) {
|
|
||||||
log.warn { "Backup not prepared. If not started via `adb shell bmgr` that's a bug" }
|
|
||||||
startedViaAdb = true
|
|
||||||
beforeBackup()
|
|
||||||
} else Unit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We don't get notified when backups ran from `adb shell bmgr backupnow` end,
|
|
||||||
* so [afterBackupFinished] will not run, so we need to find a place
|
|
||||||
*/
|
|
||||||
suspend fun finalizeBackupIfNeeded() {
|
|
||||||
if (startedViaAdb) {
|
|
||||||
log.warn { "Backup not finalized. If not started via `adb shell bmgr` that's a bug" }
|
|
||||||
startedViaAdb = false
|
|
||||||
afterBackupFinished(true) // is there a way to know if success or not?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the repo identified by [repoId] can be transferred to this device.
|
|
||||||
* This is the case when it isn't the same as the current repoId and the version is latest.
|
|
||||||
*/
|
|
||||||
fun canRecycleBackupRepo(repoId: String?, version: Byte?): Boolean {
|
|
||||||
if (repoId == null || version == null) return false
|
|
||||||
return repoId != crypto.repoId && version == VERSION
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transfers the ownership of the backup repository identified by the [oldRepoId]
|
|
||||||
* to the current user and device
|
|
||||||
* by renaming the [TopLevelFolder] of the repo to the current repoId.
|
|
||||||
*/
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun recycleBackupRepo(oldRepoId: String) {
|
|
||||||
val newRepoId = crypto.repoId
|
|
||||||
if (oldRepoId == newRepoId) return
|
|
||||||
val oldFolder = TopLevelFolder(oldRepoId)
|
|
||||||
val newFolder = TopLevelFolder(newRepoId)
|
|
||||||
backendManager.backend.rename(oldFolder, newFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Careful, this removes the entire backup repository from the backend
|
|
||||||
* and clears local blob cache.
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun removeBackupRepo() {
|
|
||||||
blobCache.clearLocalCache()
|
|
||||||
// TODO not critical, but nice to have: clear also local snapshot cache
|
|
||||||
backendManager.backend.remove(TopLevelFolder(crypto.repoId))
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) {
|
|
||||||
for (i in 1..n) {
|
|
||||||
try {
|
|
||||||
block()
|
|
||||||
return
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (i == n) throw e
|
|
||||||
log.error(e) { "Error (#$i), we'll keep trying" }
|
|
||||||
delay(1000 * i.toLong())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Essential metadata returned when storing backup data.
|
|
||||||
*
|
|
||||||
* @param chunkIds an ordered(!) list of the chunk IDs required to re-assemble the backup data.
|
|
||||||
* @param blobMap a mapping from chunk ID to [Blob] on the backend.
|
|
||||||
* Needed for fetching blobs from the backend for re-assembly.
|
|
||||||
*/
|
|
||||||
data class BackupData(
|
|
||||||
val chunkIds: List<String>,
|
|
||||||
val blobMap: Map<String, Blob>,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* The uncompressed plaintext size of all blobs.
|
|
||||||
*/
|
|
||||||
val size get() = blobMap.values.sumOf { it.uncompressedLength }.toLong()
|
|
||||||
}
|
|
|
@ -1,132 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
|
||||||
import org.calyxos.seedvault.chunker.Chunk
|
|
||||||
import org.calyxos.seedvault.chunker.Chunker
|
|
||||||
import org.calyxos.seedvault.chunker.GearTableCreator
|
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The single point for receiving data for backup.
|
|
||||||
* Data received will get split into smaller chunks, if needed.
|
|
||||||
* [Chunk]s that don't have a corresponding [Blob] in the [blobCache]
|
|
||||||
* will be passed to the [blobCreator] and have the new blob saved to the backend.
|
|
||||||
*
|
|
||||||
* Data can be received either via [addBytes] (requires matching call to [finalize])
|
|
||||||
* or via [readFromStream].
|
|
||||||
* This call is *not* thread-safe.
|
|
||||||
*/
|
|
||||||
internal class BackupReceiver(
|
|
||||||
private val blobCache: BlobCache,
|
|
||||||
private val blobCreator: BlobCreator,
|
|
||||||
private val crypto: Crypto,
|
|
||||||
private val replaceableChunker: Chunker? = null,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val chunker: Chunker by lazy {
|
|
||||||
// crypto.gearTableKey is not available at creation time, so use lazy instantiation
|
|
||||||
replaceableChunker ?: Chunker(
|
|
||||||
minSize = 1536 * 1024, // 1.5 MB
|
|
||||||
avgSize = 3 * 1024 * 1024, // 3.0 MB
|
|
||||||
maxSize = 7680 * 1024, // 7.5 MB
|
|
||||||
normalization = 1,
|
|
||||||
gearTable = GearTableCreator.create(crypto.gearTableKey),
|
|
||||||
hashFunction = { bytes ->
|
|
||||||
// this calculates the chunkId
|
|
||||||
crypto.sha256(bytes).toHexString()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
private val chunks = mutableListOf<String>()
|
|
||||||
private val blobMap = mutableMapOf<String, Blob>()
|
|
||||||
private var owner: String? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds more [bytes] to be chunked and saved.
|
|
||||||
* Must call [finalize] when done, even when an exception was thrown
|
|
||||||
* to free up this re-usable instance of [BackupReceiver].
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun addBytes(owner: String, bytes: ByteArray) {
|
|
||||||
checkOwner(owner)
|
|
||||||
chunker.addBytes(bytes).forEach { chunk ->
|
|
||||||
onNewChunk(chunk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads backup data from the given [inputStream] and returns [BackupData],
|
|
||||||
* so a call to [finalize] isn't required.
|
|
||||||
* The caller must close the [inputStream] when done.
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun readFromStream(owner: String, inputStream: InputStream): BackupData {
|
|
||||||
checkOwner(owner)
|
|
||||||
try {
|
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
||||||
var bytes = inputStream.read(buffer)
|
|
||||||
while (bytes >= 0) {
|
|
||||||
if (bytes == buffer.size) {
|
|
||||||
addBytes(owner, buffer)
|
|
||||||
} else {
|
|
||||||
addBytes(owner, buffer.copyOfRange(0, bytes))
|
|
||||||
}
|
|
||||||
bytes = inputStream.read(buffer)
|
|
||||||
}
|
|
||||||
return finalize(owner)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
finalize(owner)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Must be called after one or more calls to [addBytes] to finalize usage of this instance
|
|
||||||
* and receive the [BackupData] for snapshotting.
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun finalize(owner: String): BackupData {
|
|
||||||
checkOwner(owner)
|
|
||||||
try {
|
|
||||||
chunker.finalize().forEach { chunk ->
|
|
||||||
onNewChunk(chunk)
|
|
||||||
}
|
|
||||||
return BackupData(chunks.toList(), blobMap.toMap())
|
|
||||||
} finally {
|
|
||||||
chunks.clear()
|
|
||||||
blobMap.clear()
|
|
||||||
this.owner = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun onNewChunk(chunk: Chunk) {
|
|
||||||
chunks.add(chunk.hash)
|
|
||||||
|
|
||||||
val existingBlob = blobCache[chunk.hash]
|
|
||||||
if (existingBlob == null) {
|
|
||||||
val blob = blobCreator.createNewBlob(chunk)
|
|
||||||
blobMap[chunk.hash] = blob
|
|
||||||
blobCache.saveNewBlob(chunk.hash, blob)
|
|
||||||
} else {
|
|
||||||
blobMap[chunk.hash] = existingBlob
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkOwner(owner: String) {
|
|
||||||
if (this.owner == null) this.owner = owner
|
|
||||||
else check(this.owner == owner) { "Owned by ${this.owner}, but called from $owner" }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,273 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Context.MODE_APPEND
|
|
||||||
import android.content.Context.MODE_PRIVATE
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import com.google.protobuf.ByteString
|
|
||||||
import com.stevesoltys.seedvault.MemoryLogger
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.calyxos.seedvault.core.backends.FileInfo
|
|
||||||
import org.calyxos.seedvault.core.toByteArrayFromHex
|
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
internal const val CACHE_FILE_NAME = "blobsCache"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The filename of the file where we store which blobs are known to be corrupt
|
|
||||||
* and should not be used anymore.
|
|
||||||
* Each [BLOB_ID_SIZE] bytes are appended without separator or line breaks.
|
|
||||||
*/
|
|
||||||
@VisibleForTesting
|
|
||||||
internal const val DO_NOT_USE_FILE_NAME = "doNotUseBlobs"
|
|
||||||
|
|
||||||
private const val BLOB_ID_SIZE = 32
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Responsible for caching blobs during a backup run,
|
|
||||||
* so we can know that a blob for the given chunk ID already exists
|
|
||||||
* and does not need to be uploaded again.
|
|
||||||
*
|
|
||||||
* It builds up its cache from snapshots available on the backend
|
|
||||||
* and from the persistent cache that includes blobs that could not be added to a snapshot,
|
|
||||||
* because the backup was aborted.
|
|
||||||
*/
|
|
||||||
class BlobCache(
|
|
||||||
private val context: Context,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
|
||||||
private val blobMap = mutableMapOf<String, Blob>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This must be called before saving files to the backend to avoid uploading duplicate blobs.
|
|
||||||
*/
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun populateCache(blobs: List<FileInfo>, snapshots: List<Snapshot>) {
|
|
||||||
log.info { "Getting all blobs from backend..." }
|
|
||||||
blobMap.clear()
|
|
||||||
MemoryLogger.log()
|
|
||||||
// create map of blobId to size of blob on backend
|
|
||||||
val allowedBlobIds = blobs.associate {
|
|
||||||
Pair(it.fileHandle.name, it.size.toInt())
|
|
||||||
}.toMutableMap()
|
|
||||||
// remove known bad blob IDs from allowedBlobIds
|
|
||||||
getDoNotUseBlobIds().forEach { knownBadId ->
|
|
||||||
if (allowedBlobIds.remove(knownBadId) != null) {
|
|
||||||
log.info { "Removed known bad blob: $knownBadId" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// load local blob cache and include only blobs on backend
|
|
||||||
loadPersistentBlobCache(allowedBlobIds)
|
|
||||||
// build up mapping from chunkId to blob from available snapshots
|
|
||||||
snapshots.forEach { snapshot ->
|
|
||||||
onSnapshotLoaded(snapshot, allowedBlobIds)
|
|
||||||
}
|
|
||||||
MemoryLogger.log()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Should only be called after [populateCache] has returned.
|
|
||||||
*/
|
|
||||||
operator fun get(chunkId: String): Blob? = blobMap[chunkId]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Should only be called after [populateCache] has returned.
|
|
||||||
*
|
|
||||||
* @return true if all [chunkIds] are in cache, or false if one or more is missing.
|
|
||||||
*/
|
|
||||||
fun containsAll(chunkIds: List<String>): Boolean = chunkIds.all { chunkId ->
|
|
||||||
blobMap.containsKey(chunkId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Should get called for all new blobs as soon as they've been saved to the backend.
|
|
||||||
*
|
|
||||||
* We shouldn't need to worry about [Pruner] removing blobs that get cached here locally,
|
|
||||||
* because we do run [Pruner.removeOldSnapshotsAndPruneUnusedBlobs] only after
|
|
||||||
* a successful backup which is when we also clear cache in [clearLocalCache].
|
|
||||||
*/
|
|
||||||
fun saveNewBlob(chunkId: String, blob: Blob) {
|
|
||||||
val previous = blobMap.put(chunkId, blob)
|
|
||||||
if (previous == null) {
|
|
||||||
// persist this new blob locally in case backup gets interrupted
|
|
||||||
context.openFileOutput(CACHE_FILE_NAME, MODE_APPEND).use { outputStream ->
|
|
||||||
outputStream.write(chunkId.toByteArrayFromHex())
|
|
||||||
blob.writeDelimitedTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the cached blob mapping.
|
|
||||||
* Should be called after a backup run to free up memory.
|
|
||||||
*/
|
|
||||||
fun clear() {
|
|
||||||
log.info { "Clearing cache..." }
|
|
||||||
blobMap.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the local cache.
|
|
||||||
* Should get called after
|
|
||||||
* * changing to a different backup to prevent usage of blobs that don't exist there
|
|
||||||
* * uploading a new snapshot to prevent the persistent cache from growing indefinitely
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
fun clearLocalCache() {
|
|
||||||
log.info { "Clearing local cache..." }
|
|
||||||
context.deleteFile(CACHE_FILE_NAME)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads persistent cache from disk and adds blobs to [blobMap]
|
|
||||||
* if available in [allowedBlobIds] with the right size.
|
|
||||||
*/
|
|
||||||
private fun loadPersistentBlobCache(allowedBlobIds: Map<String, Int>) {
|
|
||||||
try {
|
|
||||||
context.openFileInput(CACHE_FILE_NAME).use { inputStream ->
|
|
||||||
val chunkIdBytes = ByteArray(32)
|
|
||||||
while (true) {
|
|
||||||
val bytesRead = inputStream.read(chunkIdBytes)
|
|
||||||
if (bytesRead != 32) break
|
|
||||||
val chunkId = chunkIdBytes.toHexString()
|
|
||||||
// parse blob
|
|
||||||
val blob = Blob.parseDelimitedFrom(inputStream)
|
|
||||||
val blobId = blob.id.hexFromProto()
|
|
||||||
// include blob only if size is equal to size on backend
|
|
||||||
val sizeOnBackend = allowedBlobIds[blobId]
|
|
||||||
if (sizeOnBackend == blob.length) {
|
|
||||||
blobMap[chunkId] = blob
|
|
||||||
} else log.warn {
|
|
||||||
if (sizeOnBackend == null) {
|
|
||||||
"Cached blob $blobId is missing from backend."
|
|
||||||
} else {
|
|
||||||
"Cached blob $blobId had different size on backend: $sizeOnBackend"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (e is FileNotFoundException) log.info { "No local blob cache found." }
|
|
||||||
else {
|
|
||||||
// If the local cache is corrupted, that's not the end of the world.
|
|
||||||
// We can still continue normally,
|
|
||||||
// but may be writing out duplicated blobs we can't re-use.
|
|
||||||
// Those will get deleted again when pruning.
|
|
||||||
// So swallow the exception.
|
|
||||||
log.error(e) { "Error loading blobs cache: " }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used for populating local [blobMap] cache.
|
|
||||||
* Adds mapping from chunkId to [Blob], if it exists on backend, i.e. part of [allowedBlobIds]
|
|
||||||
* and its size matches the one on backend, i.e. value of [allowedBlobIds].
|
|
||||||
*/
|
|
||||||
private fun onSnapshotLoaded(snapshot: Snapshot, allowedBlobIds: Map<String, Int>) {
|
|
||||||
snapshot.blobsMap.forEach { (chunkId, blob) ->
|
|
||||||
// check if referenced blob still exists on backend
|
|
||||||
val blobId = blob.id.hexFromProto()
|
|
||||||
val sizeOnBackend = allowedBlobIds[blobId]
|
|
||||||
if (sizeOnBackend == blob.length) {
|
|
||||||
// only add blob to our mapping, if it still exists
|
|
||||||
blobMap.putIfAbsent(chunkId, blob)?.let { previous ->
|
|
||||||
// If there's more than one blob for the same chunk ID, it shouldn't matter
|
|
||||||
// which one we keep on using provided both are still ok.
|
|
||||||
// When we are here, the blob exists on storage and has the same size.
|
|
||||||
// There may still be other corruption such as bit flips in one of the blobs.
|
|
||||||
if (previous.id != blob.id) log.warn {
|
|
||||||
"Chunk ID ${chunkId.substring(0..5)} had more than one blob."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else log.warn {
|
|
||||||
if (sizeOnBackend == null) {
|
|
||||||
"Blob $blobId in snapshot ${snapshot.token} is missing."
|
|
||||||
} else {
|
|
||||||
"Blob $blobId has unexpected size: $sizeOnBackend"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is expected to get called by the [Checker] when it finds a blob
|
|
||||||
* that has the expected file size, but its content hash doesn't match what we expect.
|
|
||||||
*
|
|
||||||
* It appends the given [blobId] to our [DO_NOT_USE_FILE_NAME] file.
|
|
||||||
*/
|
|
||||||
fun doNotUseBlob(blobId: ByteString) {
|
|
||||||
try {
|
|
||||||
context.openFileOutput(DO_NOT_USE_FILE_NAME, MODE_APPEND).use { outputStream ->
|
|
||||||
val bytes = blobId.toByteArray()
|
|
||||||
check(bytes.size == 32) { "Blob ID $blobId has unexpected size of ${bytes.size}" }
|
|
||||||
outputStream.write(bytes)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error(e) { "Error adding blob to do-not-use list, may be corrupted: " }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
fun getDoNotUseBlobIds(): Set<String> {
|
|
||||||
val blobsIds = mutableSetOf<String>()
|
|
||||||
try {
|
|
||||||
context.openFileInput(DO_NOT_USE_FILE_NAME).use { inputStream ->
|
|
||||||
val bytes = ByteArray(BLOB_ID_SIZE)
|
|
||||||
while (inputStream.read(bytes) == 32) {
|
|
||||||
val blobId = bytes.toHexString()
|
|
||||||
blobsIds.add(blobId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: FileNotFoundException) {
|
|
||||||
log.info { "No do-not-use list found" }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error(e) { "Our internal do-not-use list is corrupted, deleting it..." }
|
|
||||||
context.deleteFile(DO_NOT_USE_FILE_NAME)
|
|
||||||
}
|
|
||||||
return blobsIds
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this after deleting blobs from the backend,
|
|
||||||
* so we can remove those from our do-not-use list.
|
|
||||||
*/
|
|
||||||
fun onBlobsRemoved(blobIds: Set<String>) {
|
|
||||||
log.info { "${blobIds.size} blobs were removed." }
|
|
||||||
|
|
||||||
val blobsIdsToKeep = mutableSetOf<String>()
|
|
||||||
|
|
||||||
try {
|
|
||||||
context.openFileInput(DO_NOT_USE_FILE_NAME).use { inputStream ->
|
|
||||||
val bytes = ByteArray(BLOB_ID_SIZE)
|
|
||||||
while (inputStream.read(bytes) == 32) {
|
|
||||||
val blobId = bytes.toHexString()
|
|
||||||
if (blobId !in blobIds) blobsIdsToKeep.add(blobId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: FileNotFoundException) {
|
|
||||||
log.info { "No do-not-use list found, no need to remove blobs from it." }
|
|
||||||
return
|
|
||||||
} // if something else goes wrong here, we'll delete the file before next backup
|
|
||||||
context.openFileOutput(DO_NOT_USE_FILE_NAME, MODE_PRIVATE).use { outputStream ->
|
|
||||||
blobsIdsToKeep.forEach { blobId ->
|
|
||||||
val bytes = blobId.toByteArrayFromHex()
|
|
||||||
outputStream.write(bytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info { "${blobsIdsToKeep.size} blobs remain on do-not-use list." }
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,86 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import com.github.luben.zstd.ZstdOutputStream
|
|
||||||
import com.google.protobuf.ByteString
|
|
||||||
import com.stevesoltys.seedvault.MemoryLogger
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
|
||||||
import com.stevesoltys.seedvault.proto.SnapshotKt.blob
|
|
||||||
import com.stevesoltys.seedvault.repo.Padding.getPadTo
|
|
||||||
import okio.Buffer
|
|
||||||
import okio.buffer
|
|
||||||
import okio.sink
|
|
||||||
import org.calyxos.seedvault.chunker.Chunk
|
|
||||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
|
||||||
import java.io.IOException
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and uploads new blobs to the current backend.
|
|
||||||
*/
|
|
||||||
internal class BlobCreator(
|
|
||||||
private val crypto: Crypto,
|
|
||||||
private val backendManager: BackendManager,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val payloadBuffer = Buffer()
|
|
||||||
private val buffer = Buffer()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and returns a new [Blob] from the given [chunk] and uploads it to the backend.
|
|
||||||
*/
|
|
||||||
@WorkerThread
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun createNewBlob(chunk: Chunk): Blob {
|
|
||||||
// ensure buffers are cleared
|
|
||||||
payloadBuffer.clear()
|
|
||||||
buffer.clear()
|
|
||||||
|
|
||||||
// compress payload and get size
|
|
||||||
ZstdOutputStream(payloadBuffer.outputStream()).use { zstdOutputStream ->
|
|
||||||
zstdOutputStream.write(chunk.data)
|
|
||||||
}
|
|
||||||
val payloadSize = payloadBuffer.size.toInt()
|
|
||||||
val payloadSizeBytes = ByteBuffer.allocate(4).putInt(payloadSize).array()
|
|
||||||
val paddingSize = getPadTo(payloadSize) - payloadSize
|
|
||||||
|
|
||||||
// encrypt compressed payload and assemble entire blob
|
|
||||||
val bufferStream = buffer.outputStream()
|
|
||||||
bufferStream.write(VERSION.toInt())
|
|
||||||
crypto.newEncryptingStream(bufferStream, crypto.getAdForVersion()).use { cryptoStream ->
|
|
||||||
cryptoStream.write(payloadSizeBytes)
|
|
||||||
payloadBuffer.writeTo(cryptoStream)
|
|
||||||
// add padding
|
|
||||||
// we could just write 0s, but because of defense in depth, we use random bytes
|
|
||||||
cryptoStream.write(crypto.getRandomBytes(paddingSize))
|
|
||||||
}
|
|
||||||
MemoryLogger.log()
|
|
||||||
payloadBuffer.clear()
|
|
||||||
|
|
||||||
// compute hash and save blob
|
|
||||||
val sha256ByteString = buffer.sha256()
|
|
||||||
val handle = AppBackupFileType.Blob(crypto.repoId, sha256ByteString.hex())
|
|
||||||
// TODO for later: implement a backend wrapper that handles retries for transient errors
|
|
||||||
val size = backendManager.backend.save(handle).use { outputStream ->
|
|
||||||
val outputBuffer = outputStream.sink().buffer()
|
|
||||||
val length = outputBuffer.writeAll(buffer)
|
|
||||||
// flushing is important here, otherwise data doesn't get fully written!
|
|
||||||
outputBuffer.flush()
|
|
||||||
length
|
|
||||||
}
|
|
||||||
buffer.clear()
|
|
||||||
return blob {
|
|
||||||
id = ByteString.copyFrom(sha256ByteString.asByteBuffer())
|
|
||||||
length = size.toInt()
|
|
||||||
uncompressedLength = chunk.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,237 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import com.google.protobuf.ByteString
|
|
||||||
import com.stevesoltys.seedvault.MemoryLogger
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
|
||||||
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import java.security.DigestInputStream
|
|
||||||
import java.security.GeneralSecurityException
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.concurrent.ConcurrentSkipListSet
|
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlin.math.roundToLong
|
|
||||||
|
|
||||||
internal class Checker(
|
|
||||||
private val crypto: Crypto,
|
|
||||||
private val backendManager: BackendManager,
|
|
||||||
private val snapshotManager: SnapshotManager,
|
|
||||||
private val loader: Loader,
|
|
||||||
private val blobCache: BlobCache,
|
|
||||||
private val nm: BackupNotificationManager,
|
|
||||||
) {
|
|
||||||
private val log = KotlinLogging.logger { }
|
|
||||||
|
|
||||||
private var handleSize: Int? = null
|
|
||||||
private var snapshots: List<Snapshot>? = null
|
|
||||||
private val concurrencyLimit: Int
|
|
||||||
get() {
|
|
||||||
val maxConcurrent = if (backendManager.requiresNetwork) 3 else 42
|
|
||||||
return min(Runtime.getRuntime().availableProcessors(), maxConcurrent)
|
|
||||||
}
|
|
||||||
var checkerResult: CheckerResult? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun getBackupSize(): Long? {
|
|
||||||
return try {
|
|
||||||
getBackupSizeInt()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error(e) { "Error loading snapshots: " }
|
|
||||||
// we swallow this exception, because an error will be shown in the next step
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getBackupSizeInt(): Long {
|
|
||||||
// get all snapshots
|
|
||||||
val folder = TopLevelFolder(crypto.repoId)
|
|
||||||
val handles = mutableListOf<AppBackupFileType.Snapshot>()
|
|
||||||
backendManager.backend.list(folder, AppBackupFileType.Snapshot::class) { fileInfo ->
|
|
||||||
handles.add(fileInfo.fileHandle as AppBackupFileType.Snapshot)
|
|
||||||
}
|
|
||||||
val snapshots = snapshotManager.onSnapshotsLoaded(handles)
|
|
||||||
this.snapshots = snapshots // remember loaded snapshots
|
|
||||||
this.handleSize = handles.size // remember number of snapshot handles we had
|
|
||||||
|
|
||||||
// get total disk space used by snapshots
|
|
||||||
val sizeMap = mutableMapOf<ByteString, Int>() // uses blob.id as key
|
|
||||||
snapshots.forEach { snapshot ->
|
|
||||||
// add sizes to a map first, so we don't double count
|
|
||||||
snapshot.blobsMap.forEach { (_, blob) ->
|
|
||||||
sizeMap[blob.id] = blob.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sizeMap.values.sumOf { it.toLong() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@WorkerThread
|
|
||||||
suspend fun check(percent: Int) {
|
|
||||||
check(percent in 0..100) { "Percent $percent out of bounds." }
|
|
||||||
|
|
||||||
if (snapshots == null) try {
|
|
||||||
getBackupSizeInt() // just get size again to be sure we get snapshots
|
|
||||||
} catch (e: Exception) {
|
|
||||||
nm.onCheckFinishedWithError(0, 0)
|
|
||||||
checkerResult = CheckerResult.GeneralError(e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val snapshots = snapshots ?: error("Snapshots still null")
|
|
||||||
val handleSize = handleSize ?: error("Handle size still null")
|
|
||||||
check(handleSize >= snapshots.size) {
|
|
||||||
"Got $handleSize handles, but ${snapshots.size} snapshots."
|
|
||||||
}
|
|
||||||
val blobSample = getBlobSample(snapshots, percent)
|
|
||||||
val sampleSize = blobSample.sumOf { it.blob.length.toLong() }
|
|
||||||
log.info { "Blob sample has ${blobSample.size} blobs worth $sampleSize bytes." }
|
|
||||||
|
|
||||||
// check blobs concurrently
|
|
||||||
val semaphore = Semaphore(concurrencyLimit)
|
|
||||||
val size = AtomicLong()
|
|
||||||
val badChunks = ConcurrentSkipListSet<ChunkIdBlobPair>()
|
|
||||||
val lastNotification = AtomicLong()
|
|
||||||
val startTime = System.currentTimeMillis()
|
|
||||||
coroutineScope {
|
|
||||||
blobSample.forEach { (chunkId, blob) ->
|
|
||||||
// launch a new co-routine for each blob to check
|
|
||||||
launch {
|
|
||||||
// suspend here until we get a permit from the semaphore (there's free workers)
|
|
||||||
semaphore.withPermit {
|
|
||||||
try {
|
|
||||||
checkBlob(chunkId, blob)
|
|
||||||
} catch (e: HashMismatchException) {
|
|
||||||
log.error(e) { "Error loading chunk $chunkId: " }
|
|
||||||
badChunks.add(ChunkIdBlobPair(chunkId, blob))
|
|
||||||
blobCache.doNotUseBlob(blob.id)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error(e) { "Error loading chunk $chunkId: " }
|
|
||||||
// TODO we could try differentiating transient backend issues
|
|
||||||
badChunks.add(ChunkIdBlobPair(chunkId, blob))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// keep track of how much we checked and for how long
|
|
||||||
val newSize = size.addAndGet(blob.length.toLong())
|
|
||||||
val passedTime = System.currentTimeMillis() - startTime
|
|
||||||
// only log/show notification after some time has passed (throttling)
|
|
||||||
if (passedTime > lastNotification.get() + 500) {
|
|
||||||
lastNotification.set(passedTime)
|
|
||||||
val bandwidth = (newSize / (passedTime.toDouble() / 1000)).roundToLong()
|
|
||||||
val thousandth = ((newSize.toDouble() / sampleSize) * 1000).roundToInt()
|
|
||||||
log.debug { "$thousandth‰ - $bandwidth KB/sec - $newSize bytes" }
|
|
||||||
nm.showCheckNotification(bandwidth, thousandth)
|
|
||||||
MemoryLogger.log()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sampleSize != size.get()) log.error {
|
|
||||||
"Checked ${size.get()} bytes, but expected $sampleSize"
|
|
||||||
}
|
|
||||||
val passedTime = max(System.currentTimeMillis() - startTime, 1000) // no div by zero
|
|
||||||
val bandwidth = size.get() / (passedTime.toDouble() / 1000).roundToLong()
|
|
||||||
checkerResult = if (badChunks.isEmpty() && handleSize == snapshots.size && handleSize > 0) {
|
|
||||||
nm.onCheckComplete(size.get(), bandwidth)
|
|
||||||
CheckerResult.Success(snapshots, percent, size.get())
|
|
||||||
} else {
|
|
||||||
nm.onCheckFinishedWithError(size.get(), bandwidth)
|
|
||||||
CheckerResult.Error(handleSize, snapshots, badChunks)
|
|
||||||
}
|
|
||||||
this.snapshots = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
log.info { "Clearing..." }
|
|
||||||
snapshots = null
|
|
||||||
checkerResult = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getBlobSample(
|
|
||||||
snapshots: List<Snapshot>,
|
|
||||||
percent: Int,
|
|
||||||
): List<ChunkIdBlobPair> {
|
|
||||||
// split up blobs for app data and for APKs (use blob.id as key to prevent double counting)
|
|
||||||
val appBlobs = mutableMapOf<ByteString, ChunkIdBlobPair>()
|
|
||||||
val apkBlobs = mutableMapOf<ByteString, ChunkIdBlobPair>()
|
|
||||||
snapshots.forEach { snapshot ->
|
|
||||||
val appChunkIds = snapshot.appsMap.flatMap { it.value.chunkIdsList.hexFromProto() }
|
|
||||||
val apkChunkIds = snapshot.appsMap.flatMap {
|
|
||||||
it.value.apk.splitsList.flatMap { split -> split.chunkIdsList.hexFromProto() }
|
|
||||||
}
|
|
||||||
appChunkIds.forEach { chunkId ->
|
|
||||||
val blob = snapshot.blobsMap[chunkId] ?: error("No Blob for chunkId")
|
|
||||||
appBlobs[blob.id] = ChunkIdBlobPair(chunkId, blob)
|
|
||||||
}
|
|
||||||
apkChunkIds.forEach { chunkId ->
|
|
||||||
val blob = snapshot.blobsMap[chunkId] ?: error("No Blob for chunkId")
|
|
||||||
apkBlobs[blob.id] = ChunkIdBlobPair(chunkId, blob)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// calculate sizes
|
|
||||||
val appSize = appBlobs.values.sumOf { it.blob.length.toLong() }
|
|
||||||
val apkSize = apkBlobs.values.sumOf { it.blob.length.toLong() }
|
|
||||||
// let's assume it is unlikely that app data and APKs have blobs in common
|
|
||||||
val totalSize = appSize + apkSize
|
|
||||||
log.info { "Got ${appBlobs.size + apkBlobs.size} blobs worth $totalSize bytes to check." }
|
|
||||||
|
|
||||||
// calculate target sizes (how much do we want to check)
|
|
||||||
val targetSize = (totalSize * (percent.toDouble() / 100)).roundToLong()
|
|
||||||
val appTargetSize = min((targetSize * 0.75).roundToLong(), appSize) // 75% of targetSize
|
|
||||||
log.info { "Sampling $targetSize bytes of which $appTargetSize bytes for apps." }
|
|
||||||
|
|
||||||
val blobSample = mutableListOf<ChunkIdBlobPair>()
|
|
||||||
var currentSize = 0L
|
|
||||||
// check apps first until we reach their target size
|
|
||||||
val appIterator = appBlobs.values.shuffled().iterator() // random app blob iterator
|
|
||||||
while (currentSize < appTargetSize && appIterator.hasNext()) {
|
|
||||||
val pair = appIterator.next()
|
|
||||||
blobSample.add(pair)
|
|
||||||
currentSize += pair.blob.length
|
|
||||||
}
|
|
||||||
// now check APKs until we reach total targetSize
|
|
||||||
val apkIterator = apkBlobs.values.shuffled().iterator() // random APK blob iterator
|
|
||||||
while (currentSize < targetSize && apkIterator.hasNext()) {
|
|
||||||
val pair = apkIterator.next()
|
|
||||||
blobSample.add(pair)
|
|
||||||
currentSize += pair.blob.length
|
|
||||||
}
|
|
||||||
return blobSample
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkBlob(chunkId: String, blob: Blob) {
|
|
||||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
|
||||||
val storageId = blob.id.hexFromProto()
|
|
||||||
val handle = AppBackupFileType.Blob(crypto.repoId, storageId)
|
|
||||||
val readChunkId = loader.loadFile(handle, null).use { inputStream ->
|
|
||||||
DigestInputStream(inputStream, messageDigest).use { digestStream ->
|
|
||||||
digestStream.readAllBytes()
|
|
||||||
digestStream.messageDigest.digest().toHexString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (readChunkId != chunkId) throw GeneralSecurityException("ChunkId doesn't match")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ChunkIdBlobPair(val chunkId: String, val blob: Blob) : Comparable<ChunkIdBlobPair> {
|
|
||||||
override fun compareTo(other: ChunkIdBlobPair): Int {
|
|
||||||
return chunkId.compareTo(other.chunkId)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,59 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot
|
|
||||||
|
|
||||||
sealed class CheckerResult {
|
|
||||||
data class Success(
|
|
||||||
val snapshots: List<Snapshot>,
|
|
||||||
val percent: Int,
|
|
||||||
val size: Long,
|
|
||||||
) : CheckerResult()
|
|
||||||
|
|
||||||
data class Error(
|
|
||||||
/**
|
|
||||||
* This number is greater than the size of [snapshots],
|
|
||||||
* if we could not read/decrypt one or more snapshots.
|
|
||||||
*/
|
|
||||||
val existingSnapshots: Int,
|
|
||||||
val snapshots: List<Snapshot>,
|
|
||||||
/**
|
|
||||||
* The list of chunkIDs that had errors.
|
|
||||||
*/
|
|
||||||
val errorChunkIdBlobPairs: Set<ChunkIdBlobPair>,
|
|
||||||
) : CheckerResult() {
|
|
||||||
val goodSnapshots: List<Snapshot>
|
|
||||||
val badSnapshots: List<Snapshot>
|
|
||||||
|
|
||||||
init {
|
|
||||||
val good = mutableListOf<Snapshot>()
|
|
||||||
val bad = mutableListOf<Snapshot>()
|
|
||||||
val errorChunkIds = errorChunkIdBlobPairs.map { it.chunkId }.toSet()
|
|
||||||
snapshots.forEach { snapshot ->
|
|
||||||
val badChunkIds = snapshot.blobsMap.keys.intersect(errorChunkIds)
|
|
||||||
if (badChunkIds.isEmpty()) {
|
|
||||||
// snapshot doesn't contain chunks with erroneous blobs
|
|
||||||
good.add(snapshot)
|
|
||||||
} else {
|
|
||||||
// snapshot may contain chunks with erroneous blobs, check deeper
|
|
||||||
val isBad = badChunkIds.any { chunkId ->
|
|
||||||
val blob = snapshot.blobsMap[chunkId] ?: error("No blob for chunkId")
|
|
||||||
// is this chunkId/blob pair in errorChunkIdBlobPairs?
|
|
||||||
errorChunkIdBlobPairs.any { pair ->
|
|
||||||
pair.chunkId == chunkId && pair.blob == blob
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isBad) bad.add(snapshot) else good.add(snapshot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
goodSnapshots = good
|
|
||||||
badSnapshots = bad
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class GeneralError(val e: Exception) : CheckerResult()
|
|
||||||
}
|
|
|
@ -1,113 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import com.github.luben.zstd.ZstdInputStream
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import java.io.ByteArrayInputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.SequenceInputStream
|
|
||||||
import java.security.GeneralSecurityException
|
|
||||||
import java.util.Enumeration
|
|
||||||
|
|
||||||
internal class Loader(
|
|
||||||
private val crypto: Crypto,
|
|
||||||
private val backendManager: BackendManager,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads the given [fileHandle], decrypts and decompresses its content
|
|
||||||
* and returns the content as a decrypted and decompressed stream.
|
|
||||||
*
|
|
||||||
* Attention: The responsibility with closing the returned stream lies with the caller.
|
|
||||||
*
|
|
||||||
* @param cacheFile if non-null, the ciphertext of the loaded file will be cached there
|
|
||||||
* for later loading with [loadFile].
|
|
||||||
*/
|
|
||||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
|
||||||
suspend fun loadFile(fileHandle: AppBackupFileType, cacheFile: File? = null): InputStream {
|
|
||||||
val expectedHash = when (fileHandle) {
|
|
||||||
is AppBackupFileType.Snapshot -> fileHandle.hash
|
|
||||||
is AppBackupFileType.Blob -> fileHandle.name
|
|
||||||
}
|
|
||||||
return loadFromStream(backendManager.backend.load(fileHandle), expectedHash, cacheFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The responsibility with closing the returned stream lies with the caller.
|
|
||||||
*/
|
|
||||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
|
||||||
fun loadFile(file: File, expectedHash: String): InputStream {
|
|
||||||
return loadFromStream(file.inputStream(), expectedHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
|
||||||
suspend fun loadFiles(handles: List<AppBackupFileType>): InputStream {
|
|
||||||
val enumeration: Enumeration<InputStream> = object : Enumeration<InputStream> {
|
|
||||||
val iterator = handles.iterator()
|
|
||||||
|
|
||||||
override fun hasMoreElements(): Boolean {
|
|
||||||
return iterator.hasNext()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun nextElement(): InputStream {
|
|
||||||
return runBlocking { loadFile(iterator.next()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return SequenceInputStream(enumeration)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
|
||||||
private fun loadFromStream(
|
|
||||||
inputStream: InputStream,
|
|
||||||
expectedHash: String,
|
|
||||||
cacheFile: File? = null,
|
|
||||||
): InputStream {
|
|
||||||
// We load the entire ciphertext into memory,
|
|
||||||
// so we can check the SHA-256 hash before decrypting and parsing the data.
|
|
||||||
val cipherText = inputStream.use { it.readAllBytes() }
|
|
||||||
// check SHA-256 hash first thing
|
|
||||||
val sha256 = crypto.sha256(cipherText).toHexString()
|
|
||||||
if (sha256 != expectedHash) {
|
|
||||||
throw HashMismatchException("File had wrong SHA-256 hash: $expectedHash")
|
|
||||||
}
|
|
||||||
// check that we can handle the version of that snapshot
|
|
||||||
val version = cipherText[0]
|
|
||||||
if (version <= 1) throw GeneralSecurityException("Unexpected version: $version")
|
|
||||||
if (version > VERSION) throw UnsupportedVersionException(version)
|
|
||||||
// cache cipherText in cacheFile, if existing
|
|
||||||
try {
|
|
||||||
cacheFile?.outputStream()?.use { outputStream ->
|
|
||||||
outputStream.write(cipherText)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error(e) { "Error writing cache file $cacheFile: " }
|
|
||||||
cacheFile?.delete()
|
|
||||||
}
|
|
||||||
// get associated data for version, used for authenticated decryption
|
|
||||||
val ad = crypto.getAdForVersion(version)
|
|
||||||
// skip first version byte when creating cipherText stream
|
|
||||||
val byteStream = ByteArrayInputStream(cipherText, 1, cipherText.size - 1)
|
|
||||||
// decrypt, de-pad and decompress cipherText stream
|
|
||||||
val decryptingStream = crypto.newDecryptingStream(byteStream, ad)
|
|
||||||
val paddedStream = PaddedInputStream(decryptingStream)
|
|
||||||
return ZstdInputStream(paddedStream)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class HashMismatchException(msg: String? = null) : GeneralSecurityException(msg)
|
|
|
@ -1,51 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import java.io.FilterInputStream
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
|
|
||||||
internal class PaddedInputStream(inputStream: InputStream) : FilterInputStream(inputStream) {
|
|
||||||
|
|
||||||
private val size: Int
|
|
||||||
private var bytesRead: Int = 0
|
|
||||||
|
|
||||||
init {
|
|
||||||
val sizeBytes = ByteArray(4)
|
|
||||||
val bytesRead = inputStream.read(sizeBytes)
|
|
||||||
if (bytesRead != 4) {
|
|
||||||
throw IOException("Could not read padding size: ${sizeBytes.toHexString()}")
|
|
||||||
}
|
|
||||||
size = ByteBuffer.wrap(sizeBytes).getInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun read(): Int {
|
|
||||||
if (bytesRead >= size) return -1
|
|
||||||
return getReadBytes(super.read())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
|
||||||
if (bytesRead >= size) return -1
|
|
||||||
if (bytesRead + len >= size) {
|
|
||||||
return getReadBytes(super.read(b, off, size - bytesRead))
|
|
||||||
}
|
|
||||||
return getReadBytes(super.read(b, off, len))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun available(): Int {
|
|
||||||
return size - bytesRead
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getReadBytes(read: Int): Int {
|
|
||||||
if (read == -1) return -1
|
|
||||||
bytesRead += read
|
|
||||||
if (bytesRead > size) return -1
|
|
||||||
return read
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,28 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import kotlin.math.floor
|
|
||||||
import kotlin.math.log2
|
|
||||||
import kotlin.math.pow
|
|
||||||
|
|
||||||
object Padding {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pads the given [size] using the [Padmé algorithm](https://lbarman.ch/blog/padme/).
|
|
||||||
*
|
|
||||||
* @param size unpadded object length
|
|
||||||
* @return the padded object length
|
|
||||||
*/
|
|
||||||
fun getPadTo(size: Int): Int {
|
|
||||||
val e = floor(log2(size.toFloat()))
|
|
||||||
val s = floor(log2(e)) + 1
|
|
||||||
val lastBits = e - s
|
|
||||||
val bitMask = (2.toFloat().pow(lastBits) - 1).toInt()
|
|
||||||
return (size + bitMask) and bitMask.inv()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,129 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
|
||||||
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
|
||||||
import java.security.GeneralSecurityException
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.temporal.ChronoField
|
|
||||||
import java.time.temporal.TemporalAdjuster
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans up old backups data that we do not need to retain.
|
|
||||||
*/
|
|
||||||
internal class Pruner(
|
|
||||||
private val crypto: Crypto,
|
|
||||||
private val backendManager: BackendManager,
|
|
||||||
private val snapshotManager: SnapshotManager,
|
|
||||||
private val blobCache: BlobCache,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
|
||||||
private val folder get() = TopLevelFolder(crypto.repoId)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps the last 3 daily and 2 weekly snapshots (this and last week), removes all others.
|
|
||||||
* Then removes all blobs from the backend
|
|
||||||
* that are not referenced anymore by remaining snapshots.
|
|
||||||
*/
|
|
||||||
suspend fun removeOldSnapshotsAndPruneUnusedBlobs() {
|
|
||||||
// get snapshots currently available on backend
|
|
||||||
val snapshotHandles = mutableListOf<AppBackupFileType.Snapshot>()
|
|
||||||
backendManager.backend.list(folder, AppBackupFileType.Snapshot::class) { fileInfo ->
|
|
||||||
snapshotHandles.add(fileInfo.fileHandle as AppBackupFileType.Snapshot)
|
|
||||||
}
|
|
||||||
// load and parse snapshots
|
|
||||||
val snapshotMap = mutableMapOf<Long, AppBackupFileType.Snapshot>()
|
|
||||||
val snapshots = mutableListOf<Snapshot>()
|
|
||||||
snapshotHandles.forEach { handle ->
|
|
||||||
try {
|
|
||||||
val snapshot = snapshotManager.loadSnapshot(handle)
|
|
||||||
snapshotMap[snapshot.token] = handle
|
|
||||||
snapshots.add(snapshot)
|
|
||||||
} catch (e: GeneralSecurityException) {
|
|
||||||
log.error(e) { "Error loading snapshot $handle, will remove: " }
|
|
||||||
snapshotManager.removeSnapshot(handle)
|
|
||||||
} // other exceptions (like IOException) are allowed to bubble up, so we try again
|
|
||||||
}
|
|
||||||
// find out which snapshots to keep
|
|
||||||
val toKeep = getTokenToKeep(snapshotMap.keys)
|
|
||||||
log.info { "Found ${snapshots.size} snapshots, keeping ${toKeep.size}." }
|
|
||||||
// remove snapshots we aren't keeping
|
|
||||||
snapshotMap.forEach { (token, handle) ->
|
|
||||||
if (token !in toKeep) {
|
|
||||||
log.info { "Removing snapshot $token ${handle.name}" }
|
|
||||||
snapshotManager.removeSnapshot(handle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// prune unused blobs
|
|
||||||
val keptSnapshots = snapshots.filter { it.token in toKeep }
|
|
||||||
pruneUnusedBlobs(keptSnapshots)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun pruneUnusedBlobs(snapshots: List<Snapshot>) {
|
|
||||||
val blobHandles = mutableListOf<AppBackupFileType.Blob>()
|
|
||||||
backendManager.backend.list(folder, AppBackupFileType.Blob::class) { fileInfo ->
|
|
||||||
blobHandles.add(fileInfo.fileHandle as AppBackupFileType.Blob)
|
|
||||||
}
|
|
||||||
val usedBlobIds = snapshots.flatMap { snapshot ->
|
|
||||||
snapshot.blobsMap.values.map { blob ->
|
|
||||||
blob.id.hexFromProto()
|
|
||||||
}
|
|
||||||
}.toSet()
|
|
||||||
val removedBlobs = mutableSetOf<String>()
|
|
||||||
try {
|
|
||||||
blobHandles.forEach { blobHandle ->
|
|
||||||
if (blobHandle.name !in usedBlobIds) {
|
|
||||||
log.info { "Removing blob ${blobHandle.name}" }
|
|
||||||
backendManager.backend.remove(blobHandle)
|
|
||||||
removedBlobs.add(blobHandle.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (removedBlobs.isNotEmpty()) blobCache.onBlobsRemoved(removedBlobs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getTokenToKeep(tokenSet: Set<Long>): Set<Long> {
|
|
||||||
if (tokenSet.size <= 3) return tokenSet // keep at least 3 snapshots
|
|
||||||
val tokenList = tokenSet.sortedDescending()
|
|
||||||
val toKeep = mutableSetOf<Long>()
|
|
||||||
toKeep += getToKeep(tokenList, 3) // 3 daily
|
|
||||||
toKeep += getToKeep(tokenList, 2) { temporal -> // keep one from this and last week
|
|
||||||
temporal.with(ChronoField.DAY_OF_WEEK, 1)
|
|
||||||
}
|
|
||||||
// ensure we keep at least three snapshots
|
|
||||||
val tokenIterator = tokenList.iterator()
|
|
||||||
while (toKeep.size < 3 && tokenIterator.hasNext()) toKeep.add(tokenIterator.next())
|
|
||||||
return toKeep
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getToKeep(
|
|
||||||
tokenList: List<Long>,
|
|
||||||
keep: Int,
|
|
||||||
temporalAdjuster: TemporalAdjuster? = null,
|
|
||||||
): List<Long> {
|
|
||||||
val toKeep = mutableListOf<Long>()
|
|
||||||
if (keep == 0) return toKeep
|
|
||||||
var last: LocalDate? = null
|
|
||||||
for (token in tokenList) {
|
|
||||||
val date = LocalDate.ofEpochDay(token / 1000 / 60 / 60 / 24)
|
|
||||||
val period = if (temporalAdjuster == null) date else date.with(temporalAdjuster)
|
|
||||||
if (period != last) {
|
|
||||||
toKeep.add(token)
|
|
||||||
if (toKeep.size >= keep) break
|
|
||||||
last = period
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return toKeep
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,25 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import org.koin.android.ext.koin.androidContext
|
|
||||||
import org.koin.dsl.module
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
val repoModule = module {
|
|
||||||
single { AppBackupManager(get(), get(), get(), get(), get(), get()) }
|
|
||||||
single { BackupReceiver(get(), get(), get()) }
|
|
||||||
single { BlobCache(androidContext()) }
|
|
||||||
single { BlobCreator(get(), get()) }
|
|
||||||
single { Loader(get(), get()) }
|
|
||||||
single {
|
|
||||||
val snapshotFolder = File(androidContext().filesDir, FOLDER_SNAPSHOTS)
|
|
||||||
SnapshotManager(snapshotFolder, get(), get(), get())
|
|
||||||
}
|
|
||||||
factory { SnapshotCreatorFactory(androidContext(), get(), get(), get()) }
|
|
||||||
factory { Pruner(get(), get(), get(), get()) }
|
|
||||||
single { Checker(get(), get(), get(), get(), get(), get()) }
|
|
||||||
}
|
|
|
@ -1,221 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.UserManager
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.provider.Settings.Secure.ANDROID_ID
|
|
||||||
import com.google.protobuf.ByteString
|
|
||||||
import com.stevesoltys.seedvault.Clock
|
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
|
||||||
import com.stevesoltys.seedvault.metadata.BackupType
|
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata.Companion.toBackupType
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.Apk
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.App
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
|
||||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assembles snapshot information over the course of a single backup run
|
|
||||||
* and creates a [Snapshot] object in the end by calling [finalizeSnapshot].
|
|
||||||
*/
|
|
||||||
internal class SnapshotCreator(
|
|
||||||
private val context: Context,
|
|
||||||
private val clock: Clock,
|
|
||||||
private val packageService: PackageService,
|
|
||||||
private val metadataManager: MetadataManager,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val log = KotlinLogging.logger { }
|
|
||||||
|
|
||||||
private val snapshotBuilder = Snapshot.newBuilder()
|
|
||||||
private val appBuilderMap = ConcurrentHashMap<String, App.Builder>()
|
|
||||||
private val blobsMap = ConcurrentHashMap<String, Blob>()
|
|
||||||
|
|
||||||
private val launchableSystemApps by lazy {
|
|
||||||
// as we can't ask [PackageInfo] for this, we keep a set of packages around
|
|
||||||
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this after all blobs for the given [apk] have been saved to the backend.
|
|
||||||
* The [apk] must contain the ordered list of chunk IDs
|
|
||||||
* and the given [blobMap] must have one [Blob] per chunk ID.
|
|
||||||
*/
|
|
||||||
fun onApkBackedUp(
|
|
||||||
packageInfo: PackageInfo,
|
|
||||||
apk: Apk,
|
|
||||||
blobMap: Map<String, Blob>,
|
|
||||||
) {
|
|
||||||
appBuilderMap.getOrPut(packageInfo.packageName) {
|
|
||||||
App.newBuilder()
|
|
||||||
}.apply {
|
|
||||||
val label = packageInfo.applicationInfo?.loadLabel(context.packageManager)
|
|
||||||
if (label != null) name = label.toString()
|
|
||||||
setApk(apk)
|
|
||||||
}
|
|
||||||
blobsMap.putAll(blobMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this after all blobs for the package identified by the given [packageInfo]
|
|
||||||
* have been saved to the backend.
|
|
||||||
* The given [backupData] must contain the full ordered list of [BackupData.chunkIds]
|
|
||||||
* and the [BackupData.blobMap] must have one [Blob] per chunk ID.
|
|
||||||
*
|
|
||||||
* Failure to call this method results in the package effectively not getting backed up.
|
|
||||||
*/
|
|
||||||
fun onPackageBackedUp(
|
|
||||||
packageInfo: PackageInfo,
|
|
||||||
backupType: BackupType,
|
|
||||||
backupData: BackupData,
|
|
||||||
) {
|
|
||||||
val packageName = packageInfo.packageName
|
|
||||||
val isSystemApp = packageInfo.isSystemApp()
|
|
||||||
val chunkIds = backupData.chunkIds.forProto()
|
|
||||||
appBuilderMap.getOrPut(packageName) {
|
|
||||||
App.newBuilder()
|
|
||||||
}.apply {
|
|
||||||
time = clock.time()
|
|
||||||
type = backupType.forSnapshot()
|
|
||||||
val label = packageInfo.applicationInfo?.loadLabel(context.packageManager)
|
|
||||||
if (label != null) name = label.toString()
|
|
||||||
system = isSystemApp
|
|
||||||
launchableSystemApp = isSystemApp && launchableSystemApps.contains(packageName)
|
|
||||||
addAllChunkIds(chunkIds)
|
|
||||||
size = backupData.size
|
|
||||||
}
|
|
||||||
blobsMap.putAll(backupData.blobMap)
|
|
||||||
metadataManager.onPackageBackedUp(packageInfo, backupType, backupData.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this when the given [packageName] may not call our transport at all in this run,
|
|
||||||
* but we need to include data for the package in the current snapshot.
|
|
||||||
* This may happen for K/V apps like @pm@ that don't call us when their data didn't change.
|
|
||||||
*
|
|
||||||
* If we do *not* have data for the given [packageName],
|
|
||||||
* we try to extract data from the given [snapshot] (ideally we latest we have) and
|
|
||||||
* add it to the current snapshot under construction.
|
|
||||||
*
|
|
||||||
* @param warnNoData log a warning, if [snapshot] had no data for the given [packageName].
|
|
||||||
*/
|
|
||||||
fun onNoDataInCurrentRun(snapshot: Snapshot, packageName: String, isStopped: Boolean = false) {
|
|
||||||
log.info { "onKvPackageNotChanged(${snapshot.token}, $packageName)" }
|
|
||||||
|
|
||||||
if (appBuilderMap.containsKey(packageName)) {
|
|
||||||
// the system backs up K/V apps repeatedly, e.g. @pm@
|
|
||||||
log.info { " Already have data for $packageName in current snapshot, not touching it" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val app = snapshot.appsMap[packageName]
|
|
||||||
if (app == null) {
|
|
||||||
if (!isStopped) log.error {
|
|
||||||
" No changed data for $packageName, but we had no data for it"
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// get chunkIds from last snapshot
|
|
||||||
val chunkIds = app.chunkIdsList.hexFromProto() +
|
|
||||||
app.apk.splitsList.flatMap { it.chunkIdsList }.hexFromProto()
|
|
||||||
|
|
||||||
// get blobs for chunkIds
|
|
||||||
val blobMap = mutableMapOf<String, Blob>()
|
|
||||||
chunkIds.forEach { chunkId ->
|
|
||||||
val blob = snapshot.blobsMap[chunkId]
|
|
||||||
if (blob == null) log.error { " No blob for $packageName chunk $chunkId" }
|
|
||||||
else blobMap[chunkId] = blob
|
|
||||||
}
|
|
||||||
|
|
||||||
// add info to current snapshot
|
|
||||||
appBuilderMap[packageName] = app.toBuilder()
|
|
||||||
blobsMap.putAll(blobMap)
|
|
||||||
|
|
||||||
// record local metadata if this is not a stopped app
|
|
||||||
if (!isStopped) {
|
|
||||||
val packageInfo = PackageInfo().apply { this.packageName = packageName }
|
|
||||||
metadataManager.onPackageBackedUp(packageInfo, app.type.toBackupType(), app.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this after all blobs for the app icons have been saved to the backend.
|
|
||||||
*/
|
|
||||||
fun onIconsBackedUp(backupData: BackupData) {
|
|
||||||
snapshotBuilder.addAllIconChunkIds(backupData.chunkIds.forProto())
|
|
||||||
blobsMap.putAll(backupData.blobMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Must get called after all backup data was saved to the backend.
|
|
||||||
* Returns the assembled [Snapshot] which must be saved to the backend as well
|
|
||||||
* to complete the current backup run.
|
|
||||||
*
|
|
||||||
* Internal state will be cleared to free up memory.
|
|
||||||
* Still, it isn't safe to re-use an instance of this class, after it has been finalized.
|
|
||||||
*/
|
|
||||||
fun finalizeSnapshot(): Snapshot {
|
|
||||||
log.info { "finalizeSnapshot()" }
|
|
||||||
@SuppressLint("HardwareIds")
|
|
||||||
val snapshot = snapshotBuilder.apply {
|
|
||||||
version = VERSION.toInt()
|
|
||||||
token = clock.time()
|
|
||||||
name = "${Build.MANUFACTURER} ${Build.MODEL}"
|
|
||||||
user = getUserName() ?: ""
|
|
||||||
androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID) ?: ""
|
|
||||||
sdkInt = Build.VERSION.SDK_INT
|
|
||||||
androidIncremental = Build.VERSION.INCREMENTAL
|
|
||||||
d2D = true
|
|
||||||
putAllApps(appBuilderMap.mapValues { it.value.build() })
|
|
||||||
putAllBlobs(this@SnapshotCreator.blobsMap)
|
|
||||||
}.build()
|
|
||||||
// may as well fail the backup, if @pm@ isn't in it
|
|
||||||
check(MAGIC_PACKAGE_MANAGER in snapshot.appsMap) { "No metadata for @pm@" }
|
|
||||||
appBuilderMap.clear()
|
|
||||||
snapshotBuilder.clear()
|
|
||||||
blobsMap.clear()
|
|
||||||
return snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getUserName(): String? {
|
|
||||||
@Suppress("UNRESOLVED_REFERENCE") // hidden AOSP API
|
|
||||||
val perm = Manifest.permission.QUERY_USERS
|
|
||||||
return if (context.checkSelfPermission(perm) == PERMISSION_GRANTED) {
|
|
||||||
val userManager = context.getSystemService(UserManager::class.java) ?: return null
|
|
||||||
userManager.userName
|
|
||||||
} else null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun BackupType.forSnapshot(): Snapshot.BackupType = when (this) {
|
|
||||||
BackupType.KV -> Snapshot.BackupType.KV
|
|
||||||
BackupType.FULL -> Snapshot.BackupType.FULL
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Iterable<String>.forProto() = map { ByteString.fromHex(it) }
|
|
||||||
fun Iterable<ByteString>.hexFromProto() = map { it.toByteArray().toHexString() }
|
|
||||||
fun ByteString.hexFromProto() = toByteArray().toHexString()
|
|
||||||
fun Snapshot.getBlobHandles(repoId: String, chunkIds: List<String>) = chunkIds.map { chunkId ->
|
|
||||||
val blobId = blobsMap[chunkId]?.id?.hexFromProto()
|
|
||||||
?: error("Blob for $chunkId missing from snapshot $token")
|
|
||||||
AppBackupFileType.Blob(repoId, blobId)
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.stevesoltys.seedvault.Clock
|
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new [SnapshotCreator], because one is only valid for a single backup run.
|
|
||||||
*/
|
|
||||||
internal class SnapshotCreatorFactory(
|
|
||||||
private val context: Context,
|
|
||||||
private val clock: Clock,
|
|
||||||
private val packageService: PackageService,
|
|
||||||
private val metadataManager: MetadataManager,
|
|
||||||
) {
|
|
||||||
fun createSnapshotCreator() =
|
|
||||||
SnapshotCreator(context, clock, packageService, metadataManager)
|
|
||||||
}
|
|
|
@ -1,162 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.repo
|
|
||||||
|
|
||||||
import com.github.luben.zstd.ZstdOutputStream
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
|
||||||
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
|
||||||
import org.calyxos.seedvault.core.backends.Constants.appSnapshotRegex
|
|
||||||
import org.calyxos.seedvault.core.toHexString
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.security.GeneralSecurityException
|
|
||||||
|
|
||||||
internal const val FOLDER_SNAPSHOTS = "snapshots"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages interactions with snapshots, such as loading, saving and removing them.
|
|
||||||
* Also keeps a reference to the [latestSnapshot] that holds important re-usable data.
|
|
||||||
*/
|
|
||||||
internal class SnapshotManager(
|
|
||||||
private val snapshotFolderRoot: File,
|
|
||||||
private val crypto: Crypto,
|
|
||||||
private val loader: Loader,
|
|
||||||
private val backendManager: BackendManager,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
|
||||||
private val snapshotFolder: File get() = File(snapshotFolderRoot, crypto.repoId)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The latest [Snapshot]. May be stale if [onSnapshotsLoaded] has not returned
|
|
||||||
* or wasn't called since new snapshots have been created.
|
|
||||||
*/
|
|
||||||
@Volatile
|
|
||||||
var latestSnapshot: Snapshot? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this before starting a backup run with the [handles] of snapshots
|
|
||||||
* currently available on the backend.
|
|
||||||
*/
|
|
||||||
suspend fun onSnapshotsLoaded(handles: List<AppBackupFileType.Snapshot>): List<Snapshot> {
|
|
||||||
// first reset latest snapshot, otherwise we'd hang on to a stale one
|
|
||||||
// e.g. when switching to new storage without any snapshots
|
|
||||||
latestSnapshot = null
|
|
||||||
return handles.mapNotNull { snapshotHandle ->
|
|
||||||
val snapshot = try {
|
|
||||||
loadSnapshot(snapshotHandle)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// This isn't ideal, but the show must go on and we take the snapshots we can get.
|
|
||||||
// After the first load, a snapshot will get cached, so we are not hitting backend.
|
|
||||||
// TODO use a re-trying backend for snapshot loading
|
|
||||||
log.error(e) { "Error loading snapshot: $snapshotHandle" }
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
// update latest snapshot if this one is more recent
|
|
||||||
if (snapshot.token > (latestSnapshot?.token ?: 0)) latestSnapshot = snapshot
|
|
||||||
snapshot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the given [snapshot] to the backend and local cache.
|
|
||||||
*
|
|
||||||
* @throws IOException or others if saving fails.
|
|
||||||
*/
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun saveSnapshot(snapshot: Snapshot) {
|
|
||||||
// compress payload and get size
|
|
||||||
val payloadStream = ByteArrayOutputStream()
|
|
||||||
ZstdOutputStream(payloadStream).use { zstdOutputStream ->
|
|
||||||
snapshot.writeTo(zstdOutputStream)
|
|
||||||
}
|
|
||||||
val payloadSize = payloadStream.size()
|
|
||||||
val payloadSizeBytes = ByteBuffer.allocate(4).putInt(payloadSize).array()
|
|
||||||
|
|
||||||
// encrypt compressed payload and assemble entire blob
|
|
||||||
val byteStream = ByteArrayOutputStream()
|
|
||||||
byteStream.write(VERSION.toInt())
|
|
||||||
crypto.newEncryptingStream(byteStream, crypto.getAdForVersion()).use { cryptoStream ->
|
|
||||||
cryptoStream.write(payloadSizeBytes)
|
|
||||||
cryptoStream.write(payloadStream.toByteArray())
|
|
||||||
// not adding any padding here, because it doesn't matter for snapshots
|
|
||||||
}
|
|
||||||
payloadStream.reset()
|
|
||||||
val bytes = byteStream.toByteArray()
|
|
||||||
byteStream.reset()
|
|
||||||
|
|
||||||
// compute hash and save blob
|
|
||||||
val sha256 = crypto.sha256(bytes).toHexString()
|
|
||||||
val snapshotHandle = AppBackupFileType.Snapshot(crypto.repoId, sha256)
|
|
||||||
backendManager.backend.save(snapshotHandle).use { outputStream ->
|
|
||||||
outputStream.write(bytes)
|
|
||||||
}
|
|
||||||
// save to local cache while at it
|
|
||||||
try {
|
|
||||||
if (!snapshotFolder.isDirectory) snapshotFolder.mkdirs()
|
|
||||||
File(snapshotFolder, snapshotHandle.name).outputStream().use { outputStream ->
|
|
||||||
outputStream.write(bytes)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) { // we'll let this one pass
|
|
||||||
log.error(e) { "Error saving snapshot ${snapshotHandle.hash} to cache: " }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes the snapshot referenced by the given [snapshotHandle] from the backend
|
|
||||||
* and local cache.
|
|
||||||
*/
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun removeSnapshot(snapshotHandle: AppBackupFileType.Snapshot) {
|
|
||||||
backendManager.backend.remove(snapshotHandle)
|
|
||||||
// remove from cache as well
|
|
||||||
File(snapshotFolder, snapshotHandle.name).delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads and parses the snapshot referenced by the given [snapshotHandle].
|
|
||||||
* If a locally cached version exists, the backend will not be hit.
|
|
||||||
*/
|
|
||||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
|
||||||
suspend fun loadSnapshot(snapshotHandle: AppBackupFileType.Snapshot): Snapshot {
|
|
||||||
val file = File(snapshotFolder, snapshotHandle.name)
|
|
||||||
snapshotFolder.mkdirs()
|
|
||||||
val inputStream = if (file.isFile) {
|
|
||||||
try {
|
|
||||||
loader.loadFile(file, snapshotHandle.hash)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
log.error(e) { "Error loading $snapshotHandle from local cache. Trying backend..." }
|
|
||||||
loader.loadFile(snapshotHandle, file)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
loader.loadFile(snapshotHandle, file)
|
|
||||||
}
|
|
||||||
return inputStream.use { Snapshot.parseFrom(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
|
||||||
fun loadCachedSnapshots(): List<Snapshot> {
|
|
||||||
if (!snapshotFolder.isDirectory) return emptyList()
|
|
||||||
return snapshotFolder.listFiles()?.mapNotNull { file ->
|
|
||||||
val match = appSnapshotRegex.matchEntire(file.name)
|
|
||||||
if (match == null) {
|
|
||||||
log.error { "Unexpected file found: $file" }
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
loader.loadFile(file, match.groupValues[1]).use { Snapshot.parseFrom(it) }
|
|
||||||
}
|
|
||||||
} ?: throw IOException("Could not access snapshotFolder")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -23,12 +23,12 @@ import com.stevesoltys.seedvault.BackupMonitor
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState
|
import com.stevesoltys.seedvault.metadata.PackageState
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.restore.install.isInstalled
|
import com.stevesoltys.seedvault.restore.install.isInstalled
|
||||||
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||||
import com.stevesoltys.seedvault.ui.AppBackupState
|
import com.stevesoltys.seedvault.ui.AppBackupState
|
||||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
|
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
|
||||||
|
@ -54,8 +54,9 @@ internal data class AppRestoreResult(
|
||||||
internal class AppDataRestoreManager(
|
internal class AppDataRestoreManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
|
private val settingsManager: SettingsManager,
|
||||||
private val restoreCoordinator: RestoreCoordinator,
|
private val restoreCoordinator: RestoreCoordinator,
|
||||||
private val backendManager: BackendManager,
|
private val storagePluginManager: StoragePluginManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private var session: IRestoreSession? = null
|
private var session: IRestoreSession? = null
|
||||||
|
@ -83,6 +84,12 @@ internal class AppDataRestoreManager(
|
||||||
|
|
||||||
Log.d(TAG, "Starting new restore session to restore backup $token")
|
Log.d(TAG, "Starting new restore session to restore backup $token")
|
||||||
|
|
||||||
|
// if we had no token before (i.e. restore from setup wizard),
|
||||||
|
// use the token of the current restore set from now on
|
||||||
|
if (settingsManager.getToken() == null) {
|
||||||
|
settingsManager.setNewToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
// start a new restore session
|
// start a new restore session
|
||||||
val session = try {
|
val session = try {
|
||||||
getOrStartSession()
|
getOrStartSession()
|
||||||
|
@ -94,7 +101,7 @@ internal class AppDataRestoreManager(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val providerPackageName = backendManager.backend.providerPackageName
|
val providerPackageName = storagePluginManager.appPlugin.providerPackageName
|
||||||
val observer = RestoreObserver(
|
val observer = RestoreObserver(
|
||||||
restoreCoordinator = restoreCoordinator,
|
restoreCoordinator = restoreCoordinator,
|
||||||
restorableBackup = restorableBackup,
|
restorableBackup = restorableBackup,
|
||||||
|
@ -210,7 +217,7 @@ internal class AppDataRestoreManager(
|
||||||
context.stopService(foregroundServiceIntent)
|
context.stopService(foregroundServiceIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun closeSession() {
|
fun closeSession() {
|
||||||
session?.endRestoreSession()
|
session?.endRestoreSession()
|
||||||
session = null
|
session = null
|
||||||
}
|
}
|
||||||
|
@ -256,20 +263,20 @@ internal class AppDataRestoreManager(
|
||||||
/**
|
/**
|
||||||
* Restore the next chunk of packages.
|
* Restore the next chunk of packages.
|
||||||
*
|
*
|
||||||
* We need to restore packages in chunks, otherwise [BackupTransport.startRestore] in the
|
* We need to restore in chunks, otherwise [BackupTransport.startRestore] in the
|
||||||
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder transaction,
|
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder
|
||||||
* causing the entire restoration to fail due to too many package names.
|
* transaction, causing the entire restoration to fail.
|
||||||
*/
|
*/
|
||||||
private fun restoreNextPackages() {
|
private fun restoreNextPackages() {
|
||||||
// Make sure metadata for selected backup is cached before starting each chunk.
|
// Make sure metadata for selected backup is cached before starting each chunk.
|
||||||
restoreCoordinator.beforeStartRestore(restorableBackup)
|
val backupMetadata = restorableBackup.backupMetadata
|
||||||
|
restoreCoordinator.beforeStartRestore(backupMetadata)
|
||||||
|
|
||||||
val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size)
|
val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size)
|
||||||
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
|
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
|
||||||
packageIndex += packageChunk.size
|
packageIndex += packageChunk.size
|
||||||
Log.d(TAG, "restoreNextPackages() with packageIndex=$packageIndex")
|
|
||||||
|
|
||||||
val token = restorableBackup.token
|
val token = backupMetadata.token
|
||||||
val result = session.restorePackages(token, this, packageChunk, monitor)
|
val result = session.restorePackages(token, this, packageChunk, monitor)
|
||||||
|
|
||||||
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
|
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
|
||||||
|
@ -310,7 +317,6 @@ internal class AppDataRestoreManager(
|
||||||
*/
|
*/
|
||||||
override fun restoreFinished(result: Int) {
|
override fun restoreFinished(result: Int) {
|
||||||
val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
|
val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
|
||||||
Log.d(TAG, "restoreFinished($result) with chunkIndex=$chunkIndex")
|
|
||||||
chunkResults[chunkIndex] = result
|
chunkResults[chunkIndex] = result
|
||||||
|
|
||||||
// Restore next chunk if successful and there are more packages to restore.
|
// Restore next chunk if successful and there are more packages to restore.
|
||||||
|
@ -319,7 +325,6 @@ internal class AppDataRestoreManager(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "onRestoreComplete()")
|
|
||||||
// Restore finished, time to get the result.
|
// Restore finished, time to get the result.
|
||||||
onRestoreComplete(getRestoreResult(), restorableBackup)
|
onRestoreComplete(getRestoreResult(), restorableBackup)
|
||||||
closeSession()
|
closeSession()
|
||||||
|
|
|
@ -13,11 +13,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.checkbox.MaterialCheckBox
|
import com.google.android.material.checkbox.MaterialCheckBox
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
class AppSelectionFragment : Fragment() {
|
class AppSelectionFragment : Fragment() {
|
||||||
|
|
||||||
private val viewModel: RestoreViewModel by activityViewModel()
|
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
|
|
||||||
private val layoutManager = LinearLayoutManager(context)
|
private val layoutManager = LinearLayoutManager(context)
|
||||||
private val adapter = AppSelectionAdapter(lifecycleScope, this::loadIcon) { item ->
|
private val adapter = AppSelectionAdapter(lifecycleScope, this::loadIcon) { item ->
|
||||||
|
|
|
@ -12,12 +12,12 @@ import androidx.lifecycle.asLiveData
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
|
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
|
||||||
import com.stevesoltys.seedvault.ui.systemData
|
import com.stevesoltys.seedvault.ui.systemData
|
||||||
|
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
|
||||||
import com.stevesoltys.seedvault.worker.IconManager
|
import com.stevesoltys.seedvault.worker.IconManager
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
@ -25,7 +25,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
internal class SelectedAppsState(
|
internal class SelectedAppsState(
|
||||||
|
@ -38,7 +37,7 @@ private val TAG = AppSelectionManager::class.simpleName
|
||||||
|
|
||||||
internal class AppSelectionManager(
|
internal class AppSelectionManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val backendManager: BackendManager,
|
private val pluginManager: StoragePluginManager,
|
||||||
private val iconManager: IconManager,
|
private val iconManager: IconManager,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val workDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val workDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
@ -69,41 +68,31 @@ internal class AppSelectionManager(
|
||||||
val name = context.getString(data.nameRes)
|
val name = context.getString(data.nameRes)
|
||||||
SelectableAppItem(packageName, metadata.copy(name = name), true)
|
SelectableAppItem(packageName, metadata.copy(name = name), true)
|
||||||
}
|
}
|
||||||
if (restorableBackup.packageMetadataMap.isNotEmpty()) {
|
val systemItem = SelectableAppItem(
|
||||||
val systemItem = SelectableAppItem(
|
packageName = PACKAGE_NAME_SYSTEM,
|
||||||
packageName = PACKAGE_NAME_SYSTEM,
|
metadata = PackageMetadata(
|
||||||
metadata = PackageMetadata(
|
time = restorableBackup.packageMetadataMap.values.maxOf {
|
||||||
time = restorableBackup.packageMetadataMap.values.maxOf {
|
if (it.system) it.time else -1
|
||||||
if (it.system) it.time else -1
|
},
|
||||||
},
|
size = restorableBackup.packageMetadataMap.values.sumOf {
|
||||||
size = restorableBackup.packageMetadataMap.values.sumOf {
|
if (it.system) it.size ?: 0L else 0L
|
||||||
if (it.system) it.size ?: 0L else 0L
|
},
|
||||||
},
|
system = true,
|
||||||
system = true,
|
name = context.getString(R.string.backup_system_apps),
|
||||||
name = context.getString(R.string.backup_system_apps),
|
),
|
||||||
),
|
selected = isSetupWizard,
|
||||||
selected = isSetupWizard,
|
)
|
||||||
)
|
items.add(0, systemItem)
|
||||||
items.add(0, systemItem)
|
|
||||||
}
|
|
||||||
items.addAll(0, systemDataItems)
|
items.addAll(0, systemDataItems)
|
||||||
selectedApps.value =
|
selectedApps.value =
|
||||||
SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false)
|
SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false)
|
||||||
// download icons
|
// download icons
|
||||||
coroutineScope.launch(workDispatcher) {
|
coroutineScope.launch(workDispatcher) {
|
||||||
|
val plugin = pluginManager.appPlugin
|
||||||
|
val token = restorableBackup.token
|
||||||
val packagesWithIcons = try {
|
val packagesWithIcons = try {
|
||||||
if (restorableBackup.version == 1.toByte()) {
|
plugin.getInputStream(token, FILE_BACKUP_ICONS).use {
|
||||||
val backend = backendManager.backend
|
iconManager.downloadIcons(restorableBackup.version, token, it)
|
||||||
val token = restorableBackup.token
|
|
||||||
backend.load(LegacyAppBackupFile.IconsFile(token)).use {
|
|
||||||
iconManager.downloadIconsV1(token, it)
|
|
||||||
}
|
|
||||||
} else if (restorableBackup.version >= 2) {
|
|
||||||
val repoId = restorableBackup.repoId ?: error("No repoId in v2 backup")
|
|
||||||
val snapshot = restorableBackup.snapshot ?: error("No snapshot in v2 backup")
|
|
||||||
iconManager.downloadIcons(repoId, snapshot)
|
|
||||||
} else {
|
|
||||||
emptySet()
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error loading icons:", e)
|
Log.e(TAG, "Error loading icons:", e)
|
||||||
|
|
|
@ -14,11 +14,11 @@ import android.widget.Button
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import org.calyxos.backup.storage.ui.restore.FileSelectionFragment
|
import org.calyxos.backup.storage.ui.restore.FileSelectionFragment
|
||||||
import org.calyxos.backup.storage.ui.restore.FilesItem
|
import org.calyxos.backup.storage.ui.restore.FilesItem
|
||||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
internal class FilesSelectionFragment : FileSelectionFragment() {
|
internal class FilesSelectionFragment : FileSelectionFragment() {
|
||||||
|
|
||||||
override val viewModel: RestoreViewModel by activityViewModel()
|
override val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
private lateinit var button: Button
|
private lateinit var button: Button
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.restore
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.Button
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import com.stevesoltys.seedvault.R
|
|
||||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
|
||||||
|
|
||||||
class RecycleBackupFragment : Fragment() {
|
|
||||||
|
|
||||||
private val viewModel: RestoreViewModel by activityViewModel()
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?,
|
|
||||||
): View {
|
|
||||||
val v: View = inflater.inflate(R.layout.fragment_recycle_backup, container, false)
|
|
||||||
|
|
||||||
val backupName = viewModel.chosenRestorableBackup.value?.name
|
|
||||||
v.requireViewById<TextView>(R.id.descriptionView).text =
|
|
||||||
getString(R.string.restore_recycle_backup_text, backupName)
|
|
||||||
|
|
||||||
v.requireViewById<Button>(R.id.noButton).setOnClickListener {
|
|
||||||
viewModel.onRecycleBackupFinished(false)
|
|
||||||
}
|
|
||||||
v.requireViewById<Button>(R.id.yesButton).setOnClickListener {
|
|
||||||
viewModel.onRecycleBackupFinished(true)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.restore
|
||||||
|
|
||||||
|
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
|
|
||||||
|
data class RestorableBackup(val backupMetadata: BackupMetadata) {
|
||||||
|
|
||||||
|
val name: String
|
||||||
|
get() = backupMetadata.deviceName
|
||||||
|
|
||||||
|
val version: Byte
|
||||||
|
get() = backupMetadata.version
|
||||||
|
|
||||||
|
val token: Long
|
||||||
|
get() = backupMetadata.token
|
||||||
|
|
||||||
|
val salt: String
|
||||||
|
get() = backupMetadata.salt
|
||||||
|
|
||||||
|
val time: Long
|
||||||
|
get() = backupMetadata.time
|
||||||
|
|
||||||
|
val size: Long?
|
||||||
|
get() = backupMetadata.size
|
||||||
|
|
||||||
|
val deviceName: String
|
||||||
|
get() = backupMetadata.deviceName
|
||||||
|
|
||||||
|
val d2dBackup: Boolean
|
||||||
|
get() = backupMetadata.d2dBackup
|
||||||
|
|
||||||
|
val packageMetadataMap: PackageMetadataMap
|
||||||
|
get() = backupMetadata.packageMetadataMap
|
||||||
|
|
||||||
|
}
|
|
@ -8,7 +8,6 @@ package com.stevesoltys.seedvault.restore
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
|
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
||||||
|
@ -36,7 +35,6 @@ class RestoreActivity : RequireProvisioningActivity() {
|
||||||
SELECT_APPS -> showFragment(AppSelectionFragment())
|
SELECT_APPS -> showFragment(AppSelectionFragment())
|
||||||
RESTORE_APPS -> showFragment(InstallProgressFragment())
|
RESTORE_APPS -> showFragment(InstallProgressFragment())
|
||||||
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
|
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
|
||||||
RECYCLE_BACKUP -> showFragment(RecycleBackupFragment())
|
|
||||||
RESTORE_FILES -> showFragment(RestoreFilesFragment())
|
RESTORE_FILES -> showFragment(RestoreFilesFragment())
|
||||||
RESTORE_SELECT_FILES -> showFragment(FilesSelectionFragment(), true)
|
RESTORE_SELECT_FILES -> showFragment(FilesSelectionFragment(), true)
|
||||||
RESTORE_FILES_STARTED -> {
|
RESTORE_FILES_STARTED -> {
|
||||||
|
|
|
@ -17,10 +17,10 @@ import androidx.fragment.app.Fragment
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import org.calyxos.backup.storage.api.SnapshotItem
|
import org.calyxos.backup.storage.api.SnapshotItem
|
||||||
import org.calyxos.backup.storage.ui.restore.SnapshotFragment
|
import org.calyxos.backup.storage.ui.restore.SnapshotFragment
|
||||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
internal class RestoreFilesFragment : SnapshotFragment() {
|
internal class RestoreFilesFragment : SnapshotFragment() {
|
||||||
override val viewModel: RestoreViewModel by activityViewModel()
|
override val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
|
|
|
@ -22,11 +22,11 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
|
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
|
||||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
class RestoreProgressFragment : Fragment() {
|
class RestoreProgressFragment : Fragment() {
|
||||||
|
|
||||||
private val viewModel: RestoreViewModel by activityViewModel()
|
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
|
|
||||||
private val layoutManager = LinearLayoutManager(context)
|
private val layoutManager = LinearLayoutManager(context)
|
||||||
private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon)
|
private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon)
|
||||||
|
|
|
@ -39,8 +39,8 @@ class RestoreService : Service() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.i(TAG, "onDestroy")
|
Log.i(TAG, "onDestroy")
|
||||||
nm.cancelRestoreNotification()
|
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
nm.cancelRestoreNotification()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,25 +6,22 @@
|
||||||
package com.stevesoltys.seedvault.restore
|
package com.stevesoltys.seedvault.restore
|
||||||
|
|
||||||
import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE
|
import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE
|
||||||
import android.text.format.DateUtils.MINUTE_IN_MILLIS
|
import android.text.format.DateUtils.HOUR_IN_MILLIS
|
||||||
import android.text.format.DateUtils.getRelativeTimeSpanString
|
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||||
import android.text.format.Formatter.formatShortFileSize
|
import android.text.format.Formatter
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.View.GONE
|
import android.view.View.GONE
|
||||||
import android.view.View.VISIBLE
|
import android.view.View.VISIBLE
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
import com.google.android.material.color.MaterialColors
|
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
|
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
|
||||||
|
|
||||||
internal class RestoreSetAdapter(
|
internal class RestoreSetAdapter(
|
||||||
private val listener: RestorableBackupClickListener?,
|
private val listener: RestorableBackupClickListener,
|
||||||
private val items: List<RestorableBackup>,
|
private val items: List<RestorableBackup>,
|
||||||
) : Adapter<RestoreSetViewHolder>() {
|
) : Adapter<RestoreSetViewHolder>() {
|
||||||
|
|
||||||
|
@ -42,57 +39,33 @@ internal class RestoreSetAdapter(
|
||||||
|
|
||||||
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
|
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
|
||||||
|
|
||||||
private val imageView = v.requireViewById<ImageView>(R.id.imageView)
|
|
||||||
private val titleView = v.requireViewById<TextView>(R.id.titleView)
|
private val titleView = v.requireViewById<TextView>(R.id.titleView)
|
||||||
private val appView = v.requireViewById<TextView>(R.id.appView)
|
private val subtitleView = v.requireViewById<TextView>(R.id.subtitleView)
|
||||||
private val apkView = v.requireViewById<TextView>(R.id.apkView)
|
private val sizeView = v.requireViewById<TextView>(R.id.sizeView)
|
||||||
private val timeView = v.requireViewById<TextView>(R.id.timeView)
|
|
||||||
|
|
||||||
internal fun bind(item: RestorableBackup) {
|
internal fun bind(item: RestorableBackup) {
|
||||||
if (listener != null) {
|
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
|
||||||
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
|
|
||||||
}
|
|
||||||
if (item.canBeRestored) {
|
|
||||||
imageView.setImageResource(R.drawable.ic_phone_android)
|
|
||||||
} else {
|
|
||||||
imageView.setImageResource(R.drawable.ic_error_red)
|
|
||||||
}
|
|
||||||
titleView.text = item.name
|
titleView.text = item.name
|
||||||
|
|
||||||
appView.text = if (item.sizeAppData > 0) {
|
val lastBackup = getRelativeTime(item.time)
|
||||||
v.context.getString(
|
val setup = getRelativeTime(item.token)
|
||||||
R.string.restore_restore_set_apps,
|
subtitleView.text =
|
||||||
item.numAppData,
|
v.context.getString(R.string.restore_restore_set_times, lastBackup, setup)
|
||||||
formatShortFileSize(v.context, item.sizeAppData),
|
val size = item.size
|
||||||
|
if (size == null) {
|
||||||
|
sizeView.visibility = GONE
|
||||||
|
} else {
|
||||||
|
sizeView.text = v.context.getString(
|
||||||
|
R.string.restore_restore_set_size,
|
||||||
|
Formatter.formatShortFileSize(v.context, size),
|
||||||
)
|
)
|
||||||
} else {
|
sizeView.visibility = VISIBLE
|
||||||
v.context.getString(R.string.restore_restore_set_apps_no_size, item.numAppData)
|
|
||||||
}
|
}
|
||||||
appView.visibility = if (item.numAppData > 0) VISIBLE else GONE
|
|
||||||
apkView.text = if (!item.canBeRestored) {
|
|
||||||
v.context.getString(R.string.restore_restore_set_can_not_get_restored)
|
|
||||||
} else if (item.sizeApks > 0) {
|
|
||||||
v.context.getString(
|
|
||||||
R.string.restore_restore_set_apks,
|
|
||||||
item.numApks,
|
|
||||||
formatShortFileSize(v.context, item.sizeApks),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
v.context.getString(R.string.restore_restore_set_apks_no_size, item.numApks)
|
|
||||||
}
|
|
||||||
apkView.visibility = if (item.numApks > 0 || !item.canBeRestored) VISIBLE else GONE
|
|
||||||
val apkTextColor = if (item.canBeRestored) {
|
|
||||||
appView.currentTextColor
|
|
||||||
} else {
|
|
||||||
MaterialColors.getColor(apkView, R.attr.colorError)
|
|
||||||
}
|
|
||||||
apkView.setTextColor(apkTextColor)
|
|
||||||
timeView.text = getRelativeTime(item.time)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRelativeTime(time: Long): CharSequence {
|
private fun getRelativeTime(time: Long): CharSequence {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
return getRelativeTimeSpanString(time, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
|
return getRelativeTimeSpanString(time, now, HOUR_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,12 +17,11 @@ import android.widget.TextView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
|
||||||
|
|
||||||
class RestoreSetFragment : Fragment() {
|
class RestoreSetFragment : Fragment() {
|
||||||
|
|
||||||
private val viewModel: RestoreViewModel by activityViewModel()
|
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
|
|
||||||
private lateinit var listView: RecyclerView
|
private lateinit var listView: RecyclerView
|
||||||
private lateinit var progressBar: ProgressBar
|
private lateinit var progressBar: ProgressBar
|
||||||
|
|
|
@ -19,12 +19,11 @@ val restoreUiModule = module {
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
keyManager = get(),
|
keyManager = get(),
|
||||||
backupManager = get(),
|
backupManager = get(),
|
||||||
appBackupManager = get(),
|
|
||||||
restoreCoordinator = get(),
|
restoreCoordinator = get(),
|
||||||
apkRestore = get(),
|
apkRestore = get(),
|
||||||
iconManager = get(),
|
iconManager = get(),
|
||||||
storageBackup = get(),
|
storageBackup = get(),
|
||||||
backendManager = get(),
|
pluginManager = get(),
|
||||||
fileSelectionManager = get(),
|
fileSelectionManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,10 +17,8 @@ import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.asLiveData
|
import androidx.lifecycle.asLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
|
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
||||||
|
@ -32,9 +30,6 @@ import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
|
||||||
import com.stevesoltys.seedvault.restore.install.InstallResult
|
import com.stevesoltys.seedvault.restore.install.InstallResult
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.storage.StorageRestoreService
|
import com.stevesoltys.seedvault.storage.StorageRestoreService
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackupResult.ErrorResult
|
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackupResult.SuccessResult
|
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||||
|
@ -67,23 +62,22 @@ internal class RestoreViewModel(
|
||||||
keyManager: KeyManager,
|
keyManager: KeyManager,
|
||||||
backupManager: IBackupManager,
|
backupManager: IBackupManager,
|
||||||
private val restoreCoordinator: RestoreCoordinator,
|
private val restoreCoordinator: RestoreCoordinator,
|
||||||
private val appBackupManager: AppBackupManager,
|
|
||||||
private val apkRestore: ApkRestore,
|
private val apkRestore: ApkRestore,
|
||||||
private val iconManager: IconManager,
|
private val iconManager: IconManager,
|
||||||
storageBackup: StorageBackup,
|
storageBackup: StorageBackup,
|
||||||
backendManager: BackendManager,
|
pluginManager: StoragePluginManager,
|
||||||
override val fileSelectionManager: FileSelectionManager,
|
override val fileSelectionManager: FileSelectionManager,
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager, backendManager),
|
) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager),
|
||||||
RestorableBackupClickListener, SnapshotViewModel {
|
RestorableBackupClickListener, SnapshotViewModel {
|
||||||
|
|
||||||
override val isRestoreOperation = true
|
override val isRestoreOperation = true
|
||||||
var isSetupWizard = false
|
var isSetupWizard = false
|
||||||
|
|
||||||
private val appSelectionManager =
|
private val appSelectionManager =
|
||||||
AppSelectionManager(app, backendManager, iconManager, viewModelScope)
|
AppSelectionManager(app, pluginManager, iconManager, viewModelScope)
|
||||||
private val appDataRestoreManager = AppDataRestoreManager(
|
private val appDataRestoreManager = AppDataRestoreManager(
|
||||||
app, backupManager, restoreCoordinator, backendManager
|
app, backupManager, settingsManager, restoreCoordinator, pluginManager
|
||||||
)
|
)
|
||||||
|
|
||||||
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
|
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
|
||||||
|
@ -112,11 +106,20 @@ internal class RestoreViewModel(
|
||||||
private var storedSnapshot: StoredSnapshot? = null
|
private var storedSnapshot: StoredSnapshot? = null
|
||||||
|
|
||||||
internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
|
internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
|
||||||
val result = when (val backups = restoreCoordinator.getAvailableBackups()) {
|
val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->
|
||||||
is ErrorResult -> RestoreSetResult(
|
when (metadata.time) {
|
||||||
app.getString(R.string.restore_set_error) + "\n\n${backups.e}"
|
0L -> {
|
||||||
)
|
Log.d(TAG, "Ignoring RestoreSet with no last backup time: $token.")
|
||||||
is SuccessResult -> RestoreSetResult(backups.backups)
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> RestorableBackup(metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = when {
|
||||||
|
backups == null -> RestoreSetResult(app.getString(R.string.restore_set_error))
|
||||||
|
backups.isEmpty() -> RestoreSetResult(app.getString(R.string.restore_set_empty_result))
|
||||||
|
else -> RestoreSetResult(backups)
|
||||||
}
|
}
|
||||||
mRestoreSetResults.postValue(result)
|
mRestoreSetResults.postValue(result)
|
||||||
}
|
}
|
||||||
|
@ -173,28 +176,11 @@ internal class RestoreViewModel(
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
GlobalScope.launch(ioDispatcher) { iconManager.removeIcons() }
|
GlobalScope.launch(ioDispatcher) { iconManager.removeIcons() }
|
||||||
|
appDataRestoreManager.closeSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
@UiThread
|
@UiThread
|
||||||
internal fun onFinishClickedAfterRestoringAppData() {
|
internal fun onFinishClickedAfterRestoringAppData() {
|
||||||
val backup = chosenRestorableBackup.value
|
|
||||||
if (appBackupManager.canRecycleBackupRepo(backup?.repoId, backup?.version)) {
|
|
||||||
mDisplayFragment.setEvent(RECYCLE_BACKUP)
|
|
||||||
} else {
|
|
||||||
mDisplayFragment.setEvent(RESTORE_FILES)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UiThread
|
|
||||||
internal fun onRecycleBackupFinished(shouldRecycle: Boolean) {
|
|
||||||
val repoId = chosenRestorableBackup.value?.repoId
|
|
||||||
if (shouldRecycle && repoId != null) viewModelScope.launch(ioDispatcher) {
|
|
||||||
try {
|
|
||||||
appBackupManager.recycleBackupRepo(repoId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error transferring backup repo: ", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mDisplayFragment.setEvent(RESTORE_FILES)
|
mDisplayFragment.setEvent(RESTORE_FILES)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -239,7 +225,6 @@ internal enum class DisplayFragment {
|
||||||
SELECT_APPS,
|
SELECT_APPS,
|
||||||
RESTORE_APPS,
|
RESTORE_APPS,
|
||||||
RESTORE_BACKUP,
|
RESTORE_BACKUP,
|
||||||
RECYCLE_BACKUP,
|
|
||||||
RESTORE_FILES,
|
RESTORE_FILES,
|
||||||
RESTORE_SELECT_FILES,
|
RESTORE_SELECT_FILES,
|
||||||
RESTORE_FILES_STARTED,
|
RESTORE_FILES_STARTED,
|
||||||
|
|
|
@ -11,19 +11,16 @@ import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.PackageManager.GET_SIGNATURES
|
import android.content.pm.PackageManager.GET_SIGNATURES
|
||||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||||
import android.content.pm.SigningInfo
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.BackupStateManager
|
import com.stevesoltys.seedvault.BackupStateManager
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.encodeBase64
|
|
||||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
|
||||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.repo.Loader
|
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.repo.getBlobHandles
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||||
|
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||||
import com.stevesoltys.seedvault.restore.RestoreService
|
import com.stevesoltys.seedvault.restore.RestoreService
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||||
|
@ -31,20 +28,14 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
||||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
|
||||||
import com.stevesoltys.seedvault.worker.hashSignature
|
import com.stevesoltys.seedvault.worker.getSignatures
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.security.GeneralSecurityException
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private val TAG = ApkRestore::class.java.simpleName
|
private val TAG = ApkRestore::class.java.simpleName
|
||||||
|
@ -53,8 +44,7 @@ internal class ApkRestore(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
private val backupStateManager: BackupStateManager,
|
private val backupStateManager: BackupStateManager,
|
||||||
private val backendManager: BackendManager,
|
private val pluginManager: StoragePluginManager,
|
||||||
private val loader: Loader,
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin,
|
private val legacyStoragePlugin: LegacyStoragePlugin,
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
|
@ -64,7 +54,7 @@ internal class ApkRestore(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val pm = context.packageManager
|
private val pm = context.packageManager
|
||||||
private val backend get() = backendManager.backend
|
private val storagePlugin get() = pluginManager.appPlugin
|
||||||
|
|
||||||
private val mInstallResult = MutableStateFlow(InstallResult())
|
private val mInstallResult = MutableStateFlow(InstallResult())
|
||||||
val installResult = mInstallResult.asStateFlow()
|
val installResult = mInstallResult.asStateFlow()
|
||||||
|
@ -75,7 +65,7 @@ internal class ApkRestore(
|
||||||
val packages = backup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
|
val packages = backup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
|
||||||
// We need to exclude the DocumentsProvider used to retrieve backup data.
|
// We need to exclude the DocumentsProvider used to retrieve backup data.
|
||||||
// Otherwise, it gets killed when we install it, terminating our restoration.
|
// Otherwise, it gets killed when we install it, terminating our restoration.
|
||||||
if (packageName == backend.providerPackageName) return@mapNotNull null
|
if (packageName == storagePlugin.providerPackageName) return@mapNotNull null
|
||||||
// The @pm@ package needs to be included in [backup], but can't be installed like an app
|
// The @pm@ package needs to be included in [backup], but can't be installed like an app
|
||||||
if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null
|
if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null
|
||||||
// we don't filter out apps without APK, so the user can manually install them
|
// we don't filter out apps without APK, so the user can manually install them
|
||||||
|
@ -139,7 +129,6 @@ internal class ApkRestore(
|
||||||
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
|
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
|
||||||
mInstallResult.update { it.fail(packageName) }
|
mInstallResult.update { it.fail(packageName) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e::class.simpleName == "MockKException") throw e
|
|
||||||
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
|
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
|
||||||
mInstallResult.update { it.fail(packageName) }
|
mInstallResult.update { it.fail(packageName) }
|
||||||
}
|
}
|
||||||
|
@ -164,12 +153,7 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ThrowsCount")
|
@Suppress("ThrowsCount")
|
||||||
@Throws(
|
@Throws(IOException::class, SecurityException::class)
|
||||||
GeneralSecurityException::class,
|
|
||||||
UnsupportedVersionException::class,
|
|
||||||
IOException::class,
|
|
||||||
SecurityException::class,
|
|
||||||
)
|
|
||||||
private suspend fun restore(
|
private suspend fun restore(
|
||||||
backup: RestorableBackup,
|
backup: RestorableBackup,
|
||||||
packageName: String,
|
packageName: String,
|
||||||
|
@ -183,10 +167,10 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
|
|
||||||
// cache the APK and get its hash
|
// cache the APK and get its hash
|
||||||
val (cachedApk, sha256) = cacheApk(backup, packageName, metadata.baseApkChunkIds)
|
val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
|
||||||
|
|
||||||
// check APK's SHA-256 hash for backup versions before 2
|
// check APK's SHA-256 hash
|
||||||
if (backup.version < 2 && metadata.sha256 != sha256) throw SecurityException(
|
if (metadata.sha256 != sha256) throw SecurityException(
|
||||||
"Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected."
|
"Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -252,7 +236,7 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves APK splits from [Backend] and caches them locally.
|
* Retrieves APK splits from [StoragePlugin] and caches them locally.
|
||||||
*
|
*
|
||||||
* @throws SecurityException if a split has an unexpected SHA-256 hash.
|
* @throws SecurityException if a split has an unexpected SHA-256 hash.
|
||||||
* @return a list of all APKs that need to be installed
|
* @return a list of all APKs that need to be installed
|
||||||
|
@ -277,9 +261,10 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
splits.forEach { apkSplit -> // cache and check all splits
|
splits.forEach { apkSplit -> // cache and check all splits
|
||||||
val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name
|
val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name
|
||||||
val (file, sha256) = cacheApk(backup, packageName, apkSplit.chunkIds, suffix)
|
val salt = backup.salt
|
||||||
// check APK split's SHA-256 hash for backup versions before 2
|
val (file, sha256) = cacheApk(backup.version, backup.token, salt, packageName, suffix)
|
||||||
if (backup.version < 2 && apkSplit.sha256 != sha256) throw SecurityException(
|
// check APK split's SHA-256 hash
|
||||||
|
if (apkSplit.sha256 != sha256) throw SecurityException(
|
||||||
"$packageName:${apkSplit.name} has sha256 '$sha256'," +
|
"$packageName:${apkSplit.name} has sha256 '$sha256'," +
|
||||||
" but '${apkSplit.sha256}' expected."
|
" but '${apkSplit.sha256}' expected."
|
||||||
)
|
)
|
||||||
|
@ -289,37 +274,27 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves an APK from the [Backend] and caches it locally
|
* Retrieves an APK from the [StoragePlugin] and caches it locally
|
||||||
* while calculating its SHA-256 hash.
|
* while calculating its SHA-256 hash.
|
||||||
*
|
*
|
||||||
* @return a [Pair] of the cached [File] and SHA-256 hash.
|
* @return a [Pair] of the cached [File] and SHA-256 hash.
|
||||||
*/
|
*/
|
||||||
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
@Throws(IOException::class)
|
||||||
private suspend fun cacheApk(
|
private suspend fun cacheApk(
|
||||||
backup: RestorableBackup,
|
version: Byte,
|
||||||
|
token: Long,
|
||||||
|
salt: String,
|
||||||
packageName: String,
|
packageName: String,
|
||||||
chunkIds: List<String>?,
|
|
||||||
suffix: String = "",
|
suffix: String = "",
|
||||||
): Pair<File, String> {
|
): Pair<File, String> {
|
||||||
// create a cache file to write the APK into
|
// create a cache file to write the APK into
|
||||||
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
|
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
|
||||||
// copy APK to cache file and calculate SHA-256 hash while we are at it
|
// copy APK to cache file and calculate SHA-256 hash while we are at it
|
||||||
val inputStream = when (backup.version) {
|
val inputStream = if (version == 0.toByte()) {
|
||||||
0.toByte() -> {
|
legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
|
||||||
legacyStoragePlugin.getApkInputStream(backup.token, packageName, suffix)
|
} else {
|
||||||
}
|
val name = crypto.getNameForApk(salt, packageName, suffix)
|
||||||
1.toByte() -> {
|
storagePlugin.getInputStream(token, name)
|
||||||
val name = crypto.getNameForApk(backup.salt, packageName, suffix)
|
|
||||||
backend.load(LegacyAppBackupFile.Blob(backup.token, name))
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val repoId = backup.repoId ?: error("No repoId for v2 backup")
|
|
||||||
val snapshot = backup.snapshot ?: error("No snapshot for v2 backup")
|
|
||||||
val handles = chunkIds?.let {
|
|
||||||
snapshot.getBlobHandles(repoId, it)
|
|
||||||
} ?: error("No chunkIds for $packageName-$suffix")
|
|
||||||
loader.loadFiles(handles)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
|
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
|
||||||
return Pair(cachedApk, sha256)
|
return Pair(cachedApk, sha256)
|
||||||
|
@ -367,45 +342,3 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy the APK from the given [InputStream] to the given [OutputStream]
|
|
||||||
* and calculate the SHA-256 hash while at it.
|
|
||||||
*
|
|
||||||
* Both streams will be closed when the method returns.
|
|
||||||
*
|
|
||||||
* @return the APK's SHA-256 hash in Base64 format.
|
|
||||||
*/
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun copyStreamsAndGetHash(inputStream: InputStream, outputStream: OutputStream): String {
|
|
||||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
|
||||||
outputStream.use { oStream ->
|
|
||||||
inputStream.use { inputStream ->
|
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
||||||
var bytes = inputStream.read(buffer)
|
|
||||||
while (bytes >= 0) {
|
|
||||||
oStream.write(buffer, 0, bytes)
|
|
||||||
messageDigest.update(buffer, 0, bytes)
|
|
||||||
bytes = inputStream.read(buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return messageDigest.digest().encodeBase64()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a list of Base64 encoded SHA-256 signature hashes.
|
|
||||||
*/
|
|
||||||
fun SigningInfo?.getSignatures(): List<String> {
|
|
||||||
return if (this == null) {
|
|
||||||
emptyList()
|
|
||||||
} else if (hasMultipleSigners()) {
|
|
||||||
apkContentsSigners.map { signature ->
|
|
||||||
hashSignature(signature).encodeBase64()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
signingCertificateHistory.map { signature ->
|
|
||||||
hashSignature(signature).encodeBase64()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ val installModule = module {
|
||||||
factory { DeviceInfo(androidContext()) }
|
factory { DeviceInfo(androidContext()) }
|
||||||
factory { ApkSplitCompatibilityChecker(get()) }
|
factory { ApkSplitCompatibilityChecker(get()) }
|
||||||
factory {
|
factory {
|
||||||
ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) {
|
ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get()) {
|
||||||
androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks()
|
androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,7 +84,7 @@ internal class InstallProgressAdapter(
|
||||||
if (item.icon == null) iconJob = scope.launch {
|
if (item.icon == null) iconJob = scope.launch {
|
||||||
iconLoader(item, appIcon::setImageDrawable)
|
iconLoader(item, appIcon::setImageDrawable)
|
||||||
} else appIcon.setImageDrawable(item.icon)
|
} else appIcon.setImageDrawable(item.icon)
|
||||||
appName.text = item.name ?: getAppName(v.context, item.packageName)
|
appName.text = item.name ?: getAppName(v.context, item.packageName.toString())
|
||||||
appInfo.visibility = GONE
|
appInfo.visibility = GONE
|
||||||
when (item.state) {
|
when (item.state) {
|
||||||
IN_PROGRESS -> {
|
IN_PROGRESS -> {
|
||||||
|
|
|
@ -26,11 +26,11 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.restore.RestoreViewModel
|
import com.stevesoltys.seedvault.restore.RestoreViewModel
|
||||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
||||||
class InstallProgressFragment : Fragment(), InstallItemListener {
|
class InstallProgressFragment : Fragment(), InstallItemListener {
|
||||||
|
|
||||||
private val viewModel: RestoreViewModel by activityViewModel()
|
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
|
|
||||||
private val layoutManager = LinearLayoutManager(context)
|
private val layoutManager = LinearLayoutManager(context)
|
||||||
private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this)
|
private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this)
|
||||||
|
|
|
@ -11,7 +11,6 @@ import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.appcompat.widget.Toolbar
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
|
@ -42,11 +41,12 @@ class AboutDialogFragment : Fragment() {
|
||||||
contributorsView.movementMethod = linkMovementMethod
|
contributorsView.movementMethod = linkMovementMethod
|
||||||
orgsView.movementMethod = linkMovementMethod
|
orgsView.movementMethod = linkMovementMethod
|
||||||
|
|
||||||
v.requireViewById<Toolbar>(R.id.toolbar).setNavigationOnClickListener {
|
|
||||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
|
||||||
}
|
|
||||||
|
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
activity?.setTitle(R.string.about_title)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import android.annotation.StringRes
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
@ -29,6 +30,8 @@ import com.stevesoltys.seedvault.ui.notification.getAppName
|
||||||
import com.stevesoltys.seedvault.ui.systemData
|
import com.stevesoltys.seedvault.ui.systemData
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
private const val TAG = "AppListRetriever"
|
||||||
|
|
||||||
sealed class AppListItem
|
sealed class AppListItem
|
||||||
|
|
||||||
data class AppStatus(
|
data class AppStatus(
|
||||||
|
@ -59,6 +62,7 @@ internal class AppListRetriever(
|
||||||
val appListSections = linkedMapOf(
|
val appListSections = linkedMapOf(
|
||||||
AppSectionTitle(R.string.backup_section_system) to getSpecialApps(),
|
AppSectionTitle(R.string.backup_section_system) to getSpecialApps(),
|
||||||
AppSectionTitle(R.string.backup_section_user) to getApps(),
|
AppSectionTitle(R.string.backup_section_user) to getApps(),
|
||||||
|
AppSectionTitle(R.string.backup_section_not_allowed) to getNotAllowedApps()
|
||||||
).filter { it.value.isNotEmpty() }
|
).filter { it.value.isNotEmpty() }
|
||||||
|
|
||||||
return appListSections.flatMap { (sectionTitle, appList) ->
|
return appListSections.flatMap { (sectionTitle, appList) ->
|
||||||
|
@ -77,7 +81,8 @@ internal class AppListRetriever(
|
||||||
AppStatus(
|
AppStatus(
|
||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
enabled = settingsManager.isBackupEnabled(packageName),
|
enabled = settingsManager.isBackupEnabled(packageName),
|
||||||
icon = getDrawable(context, data.iconRes) ?: getIconFromPackageManager(packageName),
|
icon = data.iconRes?.let { getDrawable(context, it) }
|
||||||
|
?: getIconFromPackageManager(packageName),
|
||||||
name = context.getString(data.nameRes),
|
name = context.getString(data.nameRes),
|
||||||
time = metadata?.time ?: 0,
|
time = metadata?.time ?: 0,
|
||||||
size = metadata?.size,
|
size = metadata?.size,
|
||||||
|
@ -94,11 +99,14 @@ internal class AppListRetriever(
|
||||||
val metadata = metadataManager.getPackageMetadata(it.packageName)
|
val metadata = metadataManager.getPackageMetadata(it.packageName)
|
||||||
val time = metadata?.time ?: 0
|
val time = metadata?.time ?: 0
|
||||||
val status = metadata?.state.toAppBackupState()
|
val status = metadata?.state.toAppBackupState()
|
||||||
|
if (status == NOT_YET_BACKED_UP) {
|
||||||
|
Log.w(TAG, "No metadata available for: ${it.packageName}")
|
||||||
|
}
|
||||||
AppStatus(
|
AppStatus(
|
||||||
packageName = it.packageName,
|
packageName = it.packageName,
|
||||||
enabled = settingsManager.isBackupEnabled(it.packageName),
|
enabled = settingsManager.isBackupEnabled(it.packageName),
|
||||||
icon = getIconFromPackageManager(it.packageName),
|
icon = getIconFromPackageManager(it.packageName),
|
||||||
name = metadata?.name?.toString() ?: getAppName(context, it.packageName).toString(),
|
name = getAppName(context, it.packageName).toString(),
|
||||||
time = time,
|
time = time,
|
||||||
size = metadata?.size,
|
size = metadata?.size,
|
||||||
status = status,
|
status = status,
|
||||||
|
@ -107,18 +115,13 @@ internal class AppListRetriever(
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
return (userApps + packageService.launchableSystemApps.mapNotNull {
|
return (userApps + packageService.launchableSystemApps.mapNotNull {
|
||||||
val packageName = it.activityInfo.packageName
|
val packageName = it.activityInfo.packageName
|
||||||
if (packageName in userPackages || packageName == context.packageName) {
|
if (packageName in userPackages) return@mapNotNull null
|
||||||
// don't re-add user packages again,
|
|
||||||
// also on some ROMs we are a launchableSystemApp, so we need to exclude ourselves
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
val metadata = metadataManager.getPackageMetadata(packageName)
|
val metadata = metadataManager.getPackageMetadata(packageName)
|
||||||
AppStatus(
|
AppStatus(
|
||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
enabled = settingsManager.isBackupEnabled(packageName),
|
enabled = settingsManager.isBackupEnabled(packageName),
|
||||||
icon = getIconFromPackageManager(packageName),
|
icon = getIconFromPackageManager(packageName),
|
||||||
name = metadata?.name?.toString()
|
name = it.loadLabel(context.packageManager).toString(),
|
||||||
?: it.loadLabel(context.packageManager).toString(),
|
|
||||||
time = metadata?.time ?: 0,
|
time = metadata?.time ?: 0,
|
||||||
size = metadata?.size,
|
size = metadata?.size,
|
||||||
status = metadata?.state.toAppBackupState(),
|
status = metadata?.state.toAppBackupState(),
|
||||||
|
@ -126,6 +129,21 @@ internal class AppListRetriever(
|
||||||
}).sortedBy { it.name.lowercase(locale) }
|
}).sortedBy { it.name.lowercase(locale) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getNotAllowedApps(): List<AppStatus> {
|
||||||
|
val locale = Locale.getDefault()
|
||||||
|
return packageService.userNotAllowedApps.map {
|
||||||
|
AppStatus(
|
||||||
|
packageName = it.packageName,
|
||||||
|
enabled = settingsManager.isBackupEnabled(it.packageName),
|
||||||
|
icon = getIconFromPackageManager(it.packageName),
|
||||||
|
name = getAppName(context, it.packageName).toString(),
|
||||||
|
time = 0,
|
||||||
|
size = null,
|
||||||
|
status = FAILED_NOT_ALLOWED,
|
||||||
|
)
|
||||||
|
}.sortedBy { it.name.lowercase(locale) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun getIconFromPackageManager(packageName: String): Drawable = try {
|
private fun getIconFromPackageManager(packageName: String): Drawable = try {
|
||||||
pm.getApplicationIcon(packageName)
|
pm.getApplicationIcon(packageName)
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue