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:
|
||||
image: ghcr.io/cirruslabs/android-sdk:34
|
||||
kvm: true
|
||||
cpu: 8
|
||||
memory: 16G
|
||||
|
||||
instrumentation_tests_task:
|
||||
name: "Cirrus CI Instrumentation Tests"
|
||||
start_avd_background_script:
|
||||
sdkmanager --install "system-images;android-34;default;x86_64" "emulator";
|
||||
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
|
||||
task:
|
||||
name: Build with AOSP
|
||||
only_if: $CIRRUS_PR_LABELS =~ ".*aosp-build.*"
|
||||
timeout_in: 70m
|
||||
container:
|
||||
image: ubuntu:23.04
|
||||
cpu: 8
|
||||
memory: 32G
|
||||
build_script:
|
||||
- ./.github/scripts/build_aosp.sh aosp_arm64 ap1a userdebug android-14.0.0_r29
|
||||
always:
|
||||
pull_screenshots_script:
|
||||
adb pull /sdcard/seedvault_test_results
|
||||
screenshots_artifacts:
|
||||
path: "seedvault_test_results/**/*.mp4"
|
||||
logcat_artifacts:
|
||||
path: "seedvault_test_results/**/*.log"
|
||||
seedvault_artifacts:
|
||||
path: Seedvault.apk
|
||||
|
|
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
|
||||
sleep 60
|
||||
|
||||
D2D_BACKUP_TEST=$1
|
||||
|
||||
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
|
||||
|
||||
|
|
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
@ -20,6 +20,7 @@ jobs:
|
|||
matrix:
|
||||
android_target: [ 34 ]
|
||||
emulator_type: [ aosp_atd ]
|
||||
d2d_backup_test: [ true, false ]
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v3
|
||||
|
@ -52,7 +53,7 @@ jobs:
|
|||
disable-animations: true
|
||||
script: |
|
||||
./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
|
||||
if: always()
|
||||
|
|
7
.idea/codeStyles/Project.xml
generated
7
.idea/codeStyles/Project.xml
generated
|
@ -1,7 +1,12 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<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" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
|
|
26
Android.bp
26
Android.bp
|
@ -8,23 +8,12 @@ android_app {
|
|||
srcs: [
|
||||
"app/src/main/java/**/*.kt",
|
||||
"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: [
|
||||
"app/src/main/res",
|
||||
],
|
||||
asset_dirs: [
|
||||
"app/src/main/assets"
|
||||
],
|
||||
proto: {
|
||||
type: "lite",
|
||||
local_include_dirs: ["app/src/main/proto"],
|
||||
},
|
||||
static_libs: [
|
||||
"kotlin-stdlib-jdk8",
|
||||
"libprotobuf-java-lite",
|
||||
"androidx.core_core-ktx",
|
||||
"androidx.fragment_fragment-ktx",
|
||||
"androidx.activity_activity-ktx",
|
||||
|
@ -37,23 +26,18 @@ android_app {
|
|||
"com.google.android.material_material",
|
||||
"kotlinx-coroutines-android",
|
||||
"kotlinx-coroutines-core",
|
||||
"seedvault-lib-kotlin-logging-jvm",
|
||||
// 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",
|
||||
// storage backup lib
|
||||
"seedvault-lib-storage",
|
||||
// koin
|
||||
"seedvault-lib-koin-core-jvm", // did not manage to add this as transitive dependency
|
||||
"seedvault-lib-koin-android",
|
||||
// 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",
|
||||
|
||||
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
|
||||
* It is now possible to restore after setting up a profile
|
||||
* 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)
|
||||
|
||||
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,
|
||||
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).
|
||||
If you are having an issue/question, please look at our [FAQ](https://github.com/seedvault-app/seedvault/wiki/FAQ).
|
||||
|
||||
## Components
|
||||
|
||||
* [Local Contacts Backup](contactsbackup) - an app that backs up local on-device contacts
|
||||
* [File backup library](storage) - a library handling efficient backup of files
|
||||
([documentation](storage/doc/design.md))
|
||||
* [Storage library](storage) - a library handling efficient backup of files
|
||||
* [Seedvault app](app) - the main app where all functionality comes together
|
||||
([documentation](doc/README.md))
|
||||
|
||||
## Features
|
||||
- Backup application data to a flash drive.
|
||||
|
@ -25,27 +19,24 @@ or [ask a new question](https://github.com/seedvault-app/seedvault/discussions).
|
|||
|
||||
## 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,
|
||||
make any changes required for basic functionality,
|
||||
and any improvements possible through API changes in the OS.
|
||||
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.
|
||||
|
||||
This means that for ROMs using SeedVault it's recommended
|
||||
to use the same branch as your android version
|
||||
This means that for ROMs using SeedVault it's recommended to use the same branch as your android version
|
||||
|
||||
- This current branch `android15` is meant for usage with Android 15
|
||||
- This is indicated by the version name starting with `15`,
|
||||
and the version code starting with `35` - the Android 15 API version
|
||||
- This current branch `android14` is meant for usage with Android 14
|
||||
- This is indicated by the version name starting with `14`, and the version code starting with `34` - the Android 14 API version
|
||||
|
||||
For older versions of Android,
|
||||
check out [the branches](https://github.com/seedvault-app/seedvault/branches).
|
||||
For older versions of Android, 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
|
||||
and is not something we can support.
|
||||
Trying to use an older branch on a newer version may lead to issues 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?
|
||||
|
||||
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.
|
||||
|
||||
|
@ -69,11 +60,9 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
|
|||
## Contributing
|
||||
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
|
||||
on developing Seedvault locally.
|
||||
See [DEVELOPMENT.md](app/development/DEVELOPMENT.md) for information on developing Seedvault locally.
|
||||
|
||||
This project aims to adhere to the
|
||||
[official Kotlin coding style](https://developer.android.com/kotlin/style-guide).
|
||||
This project aims to adhere to the [official Kotlin coding style](https://developer.android.com/kotlin/style-guide).
|
||||
|
||||
## 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.
|
||||
|
||||
## License
|
||||
This application is available as open source under the terms
|
||||
of the [Apache-2.0 License](https://opensource.org/licenses/Apache-2.0).
|
||||
This application is available as open source under the terms of the [Apache-2.0 License](https://opensource.org/licenses/Apache-2.0).
|
||||
|
||||
## 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,
|
||||
under the aegis of DG Communications Networks, Content and Technology
|
||||
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
|
||||
//
|
||||
|
||||
import com.google.protobuf.gradle.id
|
||||
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.google.protobuf)
|
||||
}
|
||||
|
||||
val gitDescribe = {
|
||||
|
@ -32,6 +30,16 @@ android {
|
|||
versionNameSuffix = "-${gitDescribe()}"
|
||||
testInstrumentationRunner = "com.stevesoltys.seedvault.KoinInstrumentationTestRunner"
|
||||
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 {
|
||||
|
@ -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 {
|
||||
abortOnError = true
|
||||
|
||||
|
@ -122,7 +106,19 @@ android {
|
|||
}
|
||||
|
||||
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)
|
||||
|
||||
/**
|
||||
|
@ -148,15 +144,11 @@ dependencies {
|
|||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.google.material)
|
||||
|
||||
implementation(libs.google.protobuf.javalite)
|
||||
implementation(libs.google.tink.android)
|
||||
implementation(libs.kotlin.logging)
|
||||
implementation(libs.squareup.okio)
|
||||
|
||||
/**
|
||||
* Storage Dependencies
|
||||
*/
|
||||
implementation(project(":core"))
|
||||
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("*.aar"))
|
||||
|
||||
implementation(
|
||||
fileTree("${rootProject.rootDir}/libs").include("protobuf-kotlin-lite-3.21.12.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"))
|
||||
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
|
||||
|
||||
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
|
||||
|
||||
/**
|
||||
* Test Dependencies (do not concern the AOSP build)
|
||||
|
@ -186,7 +174,6 @@ dependencies {
|
|||
// anything less than 'implementation' fails tests run with gradlew
|
||||
testImplementation(aospLibs)
|
||||
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.hamcrest:hamcrest:2.2")
|
||||
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("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.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
|
||||
|
||||
androidTestImplementation(aospLibs)
|
||||
androidTestImplementation(kotlin("test"))
|
||||
androidTestImplementation("androidx.test:runner:1.4.0")
|
||||
androidTestImplementation("androidx.test:rules:1.4.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.3")
|
||||
|
@ -212,7 +197,7 @@ dependencies {
|
|||
|
||||
gradle.projectsEvaluated {
|
||||
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 context = this@KoinInstrumentationTestApp
|
||||
|
||||
single { spyk(PackageService(context, get(), get())) }
|
||||
single { spyk(PackageService(context, get(), get(), get())) }
|
||||
single { spyk(SettingsManager(context)) }
|
||||
|
||||
single { spyk(BackupNotificationManager(context)) }
|
||||
single { spyk(FullBackup(get(), get(), get(), get())) }
|
||||
single { spyk(KVBackup(get(), get(), get())) }
|
||||
single { spyk(FullBackup(get(), get(), get(), get(), get())) }
|
||||
single { spyk(KVBackup(get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(InputFactory()) }
|
||||
|
||||
single { spyk(FullRestore(get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(KVRestore(get(), get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(FullRestore(get(), get(), get(), get(), get())) }
|
||||
single { spyk(KVRestore(get(), get(), get(), get(), get(), get())) }
|
||||
single { spyk(OutputFactory()) }
|
||||
|
||||
viewModel {
|
||||
|
@ -53,11 +53,10 @@ class KoinInstrumentationTestApp : App() {
|
|||
keyManager = get(),
|
||||
backupManager = get(),
|
||||
restoreCoordinator = get(),
|
||||
appBackupManager = get(),
|
||||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
storageBackup = get(),
|
||||
backendManager = get(),
|
||||
pluginManager = get(),
|
||||
fileSelectionManager = get(),
|
||||
)
|
||||
)
|
||||
|
|
|
@ -5,22 +5,26 @@
|
|||
|
||||
package com.stevesoltys.seedvault
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.test.core.content.pm.PackageInfoBuilder
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.backend.saf.DocumentsProviderLegacyPlugin
|
||||
import com.stevesoltys.seedvault.backend.saf.DocumentsStorage
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
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 io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
|
@ -38,10 +42,11 @@ class PluginTest : KoinComponent {
|
|||
private val mockedSettingsManager: SettingsManager = mockk()
|
||||
private val storage = DocumentsStorage(
|
||||
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")
|
||||
private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
|
||||
|
@ -54,30 +59,30 @@ class PluginTest : KoinComponent {
|
|||
|
||||
@Before
|
||||
fun setup() = runBlocking {
|
||||
every {
|
||||
mockedSettingsManager.getSafProperties()
|
||||
} returns settingsManager.getSafProperties()
|
||||
backend.removeAll()
|
||||
every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage()
|
||||
storage.rootBackupDir?.deleteContents(context)
|
||||
?: error("Select a storage location in the app first!")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = runBlocking {
|
||||
backend.removeAll()
|
||||
storage.rootBackupDir?.deleteContents(context)
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProviderPackageName() {
|
||||
assertNotNull(backend.providerPackageName)
|
||||
assertNotNull(storagePlugin.providerPackageName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTest() = runBlocking(Dispatchers.IO) {
|
||||
assertTrue(backend.test())
|
||||
assertTrue(storagePlugin.test())
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -91,66 +96,80 @@ class PluginTest : KoinComponent {
|
|||
@Test
|
||||
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
|
||||
// no backups available initially
|
||||
assertEquals(0, backend.getAvailableBackupFileHandles().toList().size)
|
||||
assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||
|
||||
// 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)
|
||||
backend.save(LegacyAppBackupFile.Metadata(token))
|
||||
storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA)
|
||||
.writeAndClose(getRandomByteArray())
|
||||
|
||||
// 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
|
||||
backend.save(LegacyAppBackupFile.Metadata(token + 1))
|
||||
storagePlugin.startNewRestoreSet(token + 1)
|
||||
storagePlugin.initializeDevice()
|
||||
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
|
||||
.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
|
||||
backend.save(LegacyAppBackupFile.Metadata(token + 1))
|
||||
storagePlugin.initializeDevice()
|
||||
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
|
||||
.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
|
||||
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
|
||||
every { mockedSettingsManager.token } returns token
|
||||
every { mockedSettingsManager.getToken() } returns token
|
||||
|
||||
storagePlugin.startNewRestoreSet(token)
|
||||
storagePlugin.initializeDevice()
|
||||
|
||||
// write metadata
|
||||
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
|
||||
var availableBackups = backend.getAvailableBackupFileHandles().toList()
|
||||
var availableBackups = storagePlugin.getAvailableBackups()?.toList()
|
||||
check(availableBackups != null)
|
||||
assertEquals(1, availableBackups.size)
|
||||
var backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata
|
||||
assertEquals(token, backupHandle.token)
|
||||
assertEquals(token, availableBackups[0].token)
|
||||
|
||||
// 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
|
||||
backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata)
|
||||
availableBackups = backend.getAvailableBackupFileHandles().toList()
|
||||
storagePlugin.initializeDevice()
|
||||
storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA).writeAndClose(metadata)
|
||||
availableBackups = storagePlugin.getAvailableBackups()?.toList()
|
||||
check(availableBackups != null)
|
||||
assertEquals(1, availableBackups.size)
|
||||
backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata
|
||||
assertEquals(token, backupHandle.token)
|
||||
assertEquals(token, availableBackups[0].token)
|
||||
|
||||
// metadata hasn't changed
|
||||
assertReadEquals(metadata, backend.load(backupHandle))
|
||||
assertReadEquals(metadata, availableBackups[0].inputStreamRetriever())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Suppress("Deprecation")
|
||||
fun v0testApkWriteRead() = runBlocking {
|
||||
// initialize storage with given token
|
||||
initStorage(token)
|
||||
|
||||
// write random bytes as APK
|
||||
val apk1 = getRandomByteArray(1337 * 1024)
|
||||
backend.save(LegacyAppBackupFile.Blob(token, "${packageInfo.packageName}.apk"))
|
||||
.writeAndClose(apk1)
|
||||
storagePlugin.getOutputStream(token, "${packageInfo.packageName}.apk").writeAndClose(apk1)
|
||||
|
||||
// assert that read APK bytes match what was written
|
||||
assertReadEquals(
|
||||
|
@ -162,7 +181,7 @@ class PluginTest : KoinComponent {
|
|||
val suffix2 = getRandomBase64(23)
|
||||
val apk2 = getRandomByteArray(23 * 1024 * 1024)
|
||||
|
||||
backend.save(LegacyAppBackupFile.Blob(token, "${packageInfo2.packageName}$suffix2.apk"))
|
||||
storagePlugin.getOutputStream(token, "${packageInfo2.packageName}$suffix2.apk")
|
||||
.writeAndClose(apk2)
|
||||
|
||||
// assert that read APK bytes match what was written
|
||||
|
@ -180,27 +199,42 @@ class PluginTest : KoinComponent {
|
|||
val name1 = getRandomBase64()
|
||||
val name2 = getRandomBase64()
|
||||
|
||||
// no data available initially
|
||||
assertFalse(storagePlugin.hasData(token, name1))
|
||||
assertFalse(storagePlugin.hasData(token, name2))
|
||||
|
||||
// write full backup data
|
||||
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
|
||||
assertReadEquals(data, backend.load(LegacyAppBackupFile.Blob(token, name1)))
|
||||
assertReadEquals(data, storagePlugin.getInputStream(token, name1))
|
||||
|
||||
// write and check data for second package
|
||||
val data2 = getRandomByteArray(5 * 1024 * 1024)
|
||||
backend.save(LegacyAppBackupFile.Blob(token, name2)).writeAndClose(data2)
|
||||
assertReadEquals(data2, backend.load(LegacyAppBackupFile.Blob(token, name2)))
|
||||
storagePlugin.getOutputStream(token, name2).writeAndClose(data2)
|
||||
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
|
||||
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
|
||||
backend.remove(LegacyAppBackupFile.Blob(token, name2))
|
||||
storagePlugin.removeData(token, name2)
|
||||
assertFalse(storagePlugin.hasData(token, name2))
|
||||
}
|
||||
|
||||
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 androidx.test.uiautomator.Until
|
||||
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.transport.backup.FullBackup
|
||||
import com.stevesoltys.seedvault.transport.backup.InputFactory
|
||||
|
@ -20,12 +21,9 @@ import io.mockk.every
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import org.koin.core.component.get
|
||||
import java.security.DigestInputStream
|
||||
import java.security.MessageDigest
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.test.fail
|
||||
|
||||
internal interface LargeBackupTestBase : LargeTestBase {
|
||||
|
||||
|
@ -76,6 +74,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
full = mutableMapOf(),
|
||||
kv = mutableMapOf(),
|
||||
userApps = packageService.userApps,
|
||||
userNotAllowedApps = packageService.userNotAllowedApps
|
||||
)
|
||||
|
||||
val completed = spyOnBackup(backupResult)
|
||||
|
@ -112,7 +111,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
var data = mutableMapOf<String, ByteArray>()
|
||||
|
||||
coEvery {
|
||||
spyKVBackup.performBackup(any(), any(), any())
|
||||
spyKVBackup.performBackup(any(), any(), any(), any(), any())
|
||||
} answers {
|
||||
packageName = firstArg<PackageInfo>().packageName
|
||||
callOriginal()
|
||||
|
@ -155,11 +154,10 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
|
||||
private fun spyOnFullBackupData(backupResult: SeedvaultLargeTestResult) {
|
||||
var packageName: String? = null
|
||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
var digestInputStream: DigestInputStream? = null
|
||||
var dataIntercept = ByteArrayOutputStream()
|
||||
|
||||
coEvery {
|
||||
spyFullBackup.performFullBackup(any(), any(), any())
|
||||
spyFullBackup.performFullBackup(any(), any(), any(), any(), any())
|
||||
} answers {
|
||||
packageName = firstArg<PackageInfo>().packageName
|
||||
callOriginal()
|
||||
|
@ -168,19 +166,20 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
every {
|
||||
spyInputFactory.getInputStream(any())
|
||||
} answers {
|
||||
digestInputStream = DigestInputStream(callOriginal(), messageDigest)
|
||||
digestInputStream!!
|
||||
InputStreamIntercept(
|
||||
inputStream = callOriginal(),
|
||||
intercept = dataIntercept
|
||||
)
|
||||
}
|
||||
|
||||
coEvery {
|
||||
every {
|
||||
spyFullBackup.finishBackup()
|
||||
} answers {
|
||||
val result = callOriginal()
|
||||
val digest = digestInputStream?.messageDigest ?: fail("No digestInputStream")
|
||||
backupResult.full[packageName!!] = digest.digest().toHexString()
|
||||
backupResult.full[packageName!!] = dataIntercept.toByteArray().sha256()
|
||||
|
||||
packageName = null
|
||||
digest.reset()
|
||||
dataIntercept = ByteArrayOutputStream()
|
||||
result
|
||||
}
|
||||
}
|
||||
|
@ -191,18 +190,14 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
|||
clearMocks(spyBackupNotificationManager)
|
||||
|
||||
every {
|
||||
spyBackupNotificationManager.onBackupSuccess(any(), any(), any())
|
||||
spyBackupNotificationManager.onBackupFinished(any(), any(), any(), any())
|
||||
} answers {
|
||||
val success = firstArg<Boolean>()
|
||||
assert(success) { "Backup failed." }
|
||||
|
||||
callOriginal()
|
||||
completed.set(true)
|
||||
}
|
||||
every {
|
||||
spyBackupNotificationManager.onBackupError()
|
||||
} answers {
|
||||
callOriginal()
|
||||
completed.set(true)
|
||||
fail("Backup failed.")
|
||||
}
|
||||
|
||||
return completed
|
||||
}
|
||||
|
|
|
@ -8,13 +8,12 @@ package com.stevesoltys.seedvault.e2e
|
|||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
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.RestoreScreen
|
||||
import com.stevesoltys.seedvault.transport.restore.FullRestore
|
||||
import com.stevesoltys.seedvault.transport.restore.KVRestore
|
||||
import com.stevesoltys.seedvault.transport.restore.OutputFactory
|
||||
import io.mockk.Call
|
||||
import io.mockk.MockKAnswerScope
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
|
@ -23,11 +22,8 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import org.koin.core.component.get
|
||||
import java.security.DigestOutputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.test.fail
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
internal interface LargeRestoreTestBase : LargeTestBase {
|
||||
|
||||
|
@ -67,6 +63,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
full = mutableMapOf(),
|
||||
kv = mutableMapOf(),
|
||||
userApps = emptyList(), // will update everything below this after restore
|
||||
userNotAllowedApps = emptyList()
|
||||
)
|
||||
|
||||
spyOnRestoreData(result)
|
||||
|
@ -100,6 +97,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
|
||||
return result.copy(
|
||||
userApps = packageService.userApps,
|
||||
userNotAllowedApps = packageService.userNotAllowedApps
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -165,26 +163,14 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
|
||||
clearMocks(spyKVRestore)
|
||||
|
||||
fun initializeStateBlock(
|
||||
packageInfoIndex: Int
|
||||
): MockKAnswerScope<Unit, Unit>.(Call) -> Unit = {
|
||||
packageName = arg<PackageInfo>(packageInfoIndex).packageName
|
||||
coEvery {
|
||||
spyKVRestore.initializeState(any(), any(), any(), any(), any())
|
||||
} answers {
|
||||
packageName = arg<PackageInfo>(3).packageName
|
||||
restoreResult.kv[packageName!!] = mutableMapOf()
|
||||
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 {
|
||||
spyOutputFactory.getBackupDataOutput(any())
|
||||
} answers {
|
||||
|
@ -198,61 +184,47 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
|||
|
||||
private fun spyOnFullRestoreData(restoreResult: SeedvaultLargeTestResult) {
|
||||
var packageName: String? = null
|
||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||
var digestOutputStream: DigestOutputStream? = null
|
||||
var dataIntercept = ByteArrayOutputStream()
|
||||
|
||||
clearMocks(spyFullRestore)
|
||||
|
||||
fun initializeStateBlock(
|
||||
packageInfoIndex: Int
|
||||
): MockKAnswerScope<Unit, Unit>.(Call) -> Unit = {
|
||||
coEvery {
|
||||
spyFullRestore.initializeState(any(), any(), any(), any())
|
||||
} answers {
|
||||
packageName?.let {
|
||||
// sometimes finishRestore() doesn't get called, so get data from last package here
|
||||
digestOutputStream?.messageDigest?.let { digest ->
|
||||
restoreResult.full[packageName!!] = digest.digest().toHexString()
|
||||
}
|
||||
restoreResult.full[it] = dataIntercept.toByteArray().sha256()
|
||||
}
|
||||
|
||||
packageName = arg<PackageInfo>(packageInfoIndex).packageName
|
||||
packageName = arg<PackageInfo>(3).packageName
|
||||
dataIntercept = ByteArrayOutputStream()
|
||||
|
||||
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 {
|
||||
spyOutputFactory.getOutputStream(any())
|
||||
} answers {
|
||||
digestOutputStream = DigestOutputStream(callOriginal(), messageDigest)
|
||||
digestOutputStream!!
|
||||
OutputStreamIntercept(
|
||||
outputStream = callOriginal(),
|
||||
intercept = dataIntercept
|
||||
)
|
||||
}
|
||||
|
||||
every {
|
||||
spyFullRestore.abortFullRestore()
|
||||
} answers {
|
||||
packageName = null
|
||||
digestOutputStream?.messageDigest?.reset()
|
||||
dataIntercept = ByteArrayOutputStream()
|
||||
callOriginal()
|
||||
}
|
||||
|
||||
every {
|
||||
spyFullRestore.finishRestore()
|
||||
} answers {
|
||||
val digest = digestOutputStream?.messageDigest ?: fail("No digestOutputStream")
|
||||
restoreResult.full[packageName!!] = digest.digest().toHexString()
|
||||
restoreResult.full[packageName!!] = dataIntercept.toByteArray().sha256()
|
||||
|
||||
packageName = null
|
||||
digest.reset()
|
||||
dataIntercept = ByteArrayOutputStream()
|
||||
callOriginal()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,14 +49,14 @@ internal interface LargeTestBase : KoinComponent {
|
|||
|
||||
companion object {
|
||||
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 testStoragePath get() = "$externalStorageDir/$TEST_STORAGE_FOLDER"
|
||||
|
||||
val testResultPath get() = "$externalStorageDir/$TEST_RESULT_FOLDER"
|
||||
val testVideoPath get() = "$externalStorageDir/$TEST_VIDEO_FOLDER"
|
||||
|
||||
val targetContext: Context
|
||||
get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
@ -85,6 +85,7 @@ internal interface LargeTestBase : KoinComponent {
|
|||
|
||||
fun resetApplicationState() {
|
||||
backupManager.setAutoRestore(false)
|
||||
settingsManager.setNewToken(null)
|
||||
|
||||
val sharedPreferences = permitDiskReads {
|
||||
PreferenceManager.getDefaultSharedPreferences(targetContext)
|
||||
|
@ -112,9 +113,11 @@ internal interface LargeTestBase : KoinComponent {
|
|||
}
|
||||
|
||||
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 timeStamp = simpleDateFormat.format(Calendar.getInstance().time)
|
||||
return "${timeStamp}_${testName.replace(" ", "_")}"
|
||||
return "${timeStamp}_${d2d}_${testName.replace(" ", "_")}"
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
|
@ -123,7 +126,7 @@ internal interface LargeTestBase : KoinComponent {
|
|||
keepRecordingScreen: AtomicBoolean,
|
||||
testName: String,
|
||||
) {
|
||||
val folder = testResultPath
|
||||
val folder = testVideoPath
|
||||
runCommand("mkdir -p $folder")
|
||||
|
||||
val fileName = testResultFilename(testName)
|
||||
|
@ -149,7 +152,7 @@ internal interface LargeTestBase : KoinComponent {
|
|||
|
||||
// write logcat to file
|
||||
val fileName = testResultFilename(testName)
|
||||
runCommand("logcat -d -f $testResultPath/$fileName.log")
|
||||
runCommand("logcat -d -f $testVideoPath/$fileName.log")
|
||||
}
|
||||
|
||||
fun uninstallPackages(packages: Collection<PackageInfo>) {
|
||||
|
@ -162,7 +165,7 @@ internal interface LargeTestBase : KoinComponent {
|
|||
|
||||
fun clearTestBackups() {
|
||||
File(testStoragePath).deleteRecursively()
|
||||
File(testResultPath).deleteRecursively()
|
||||
File(testVideoPath).deleteRecursively()
|
||||
}
|
||||
|
||||
fun changeBackupLocation(
|
||||
|
@ -225,7 +228,6 @@ internal interface LargeTestBase : KoinComponent {
|
|||
|
||||
fun confirmCode() {
|
||||
RecoveryCodeScreen {
|
||||
startNewBackupButton.click()
|
||||
confirmCodeButton.click()
|
||||
|
||||
verifyCodeButton.scrollTo().click()
|
||||
|
|
|
@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.e2e
|
|||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
|
@ -45,11 +46,23 @@ internal abstract class SeedvaultLargeTest :
|
|||
clearTestBackups()
|
||||
|
||||
runCommand("bmgr enable true")
|
||||
sleep(60_000)
|
||||
runCommand("bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport")
|
||||
sleep(5000)
|
||||
sleep(60_000)
|
||||
|
||||
startRecordingTest(keepRecordingScreen, name.methodName)
|
||||
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
|
||||
|
|
|
@ -24,6 +24,7 @@ internal data class SeedvaultLargeTestResult(
|
|||
val full: MutableMap<String, String>,
|
||||
val kv: MutableMap<String, MutableMap<String, String>>,
|
||||
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
|
||||
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.util.Log
|
||||
import androidx.test.filters.LargeTest
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTest
|
||||
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTestResult
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
import com.stevesoltys.seedvault.transport.backup.isStopped
|
||||
import org.junit.Test
|
||||
|
||||
@LargeTest
|
||||
|
@ -26,15 +23,12 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
|
|||
confirmCode()
|
||||
}
|
||||
|
||||
if (settingsManager.getSafProperties() == null) {
|
||||
if (settingsManager.getSafStorage() == null) {
|
||||
chooseStorageLocation()
|
||||
} else {
|
||||
changeBackupLocation()
|
||||
}
|
||||
|
||||
launchStoppedApps()
|
||||
launchBackupActivity()
|
||||
|
||||
val backupResult = performBackup()
|
||||
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(
|
||||
backup: 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 useAnywayButton = findObject { text("Use anyway") }
|
||||
val useAnywayButton = findObject { text("USE ANYWAY") }
|
||||
|
||||
val initializingText: BySelector = By.textContains("Initializing backup location")
|
||||
}
|
||||
|
|
|
@ -9,8 +9,6 @@ import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
|
|||
|
||||
object RecoveryCodeScreen : UiDeviceScreen<RecoveryCodeScreen>() {
|
||||
|
||||
val startNewBackupButton = findObject { text("Start new") }
|
||||
|
||||
val confirmCodeButton = findObject { text("Confirm code") }
|
||||
|
||||
val verifyCodeButton = findObject { text("Verify") }
|
||||
|
|
|
@ -9,9 +9,7 @@ import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
|
|||
|
||||
object RestoreScreen : UiDeviceScreen<RestoreScreen>() {
|
||||
|
||||
val backupListItem = findObject {
|
||||
textContains("Android SDK") // device name of test backups
|
||||
}
|
||||
val backupListItem = findObject { textContains("Last 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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
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.SettingsManager
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
@ -30,9 +30,9 @@ class PackageServiceTest : KoinComponent {
|
|||
|
||||
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
|
||||
fun testNotAllowedPackages() {
|
||||
|
@ -65,6 +65,6 @@ class PackageServiceTest : KoinComponent {
|
|||
assertTrue(packageService.shouldIncludeAppInBackup(packageInfo.packageName))
|
||||
|
||||
// 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"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.stevesoltys.seedvault"
|
||||
android:versionCode="35050000"
|
||||
android:versionName="15-5.0">
|
||||
android:versionCode="34040010"
|
||||
android:versionName="14-4.1">
|
||||
<!--
|
||||
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.
|
||||
|
@ -101,9 +101,7 @@
|
|||
<activity
|
||||
android:name=".settings.SettingsActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.storage.StorageActivity"
|
||||
|
@ -116,14 +114,12 @@
|
|||
|
||||
<activity
|
||||
android:name=".ui.recoverycode.RecoveryCodeActivity"
|
||||
android:label="@string/recovery_code_title"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
android:label="@string/recovery_code_title" />
|
||||
|
||||
<activity
|
||||
android:name=".restore.RestoreActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/restore_title"
|
||||
android:launchMode="singleTask"
|
||||
android:permission="com.stevesoltys.seedvault.RESTORE_BACKUP">
|
||||
<intent-filter>
|
||||
<action android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" />
|
||||
|
@ -131,11 +127,6 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.check.AppCheckResultActivity"
|
||||
android:label="@string/notification_checking_finished_title"
|
||||
android:launchMode="singleTask"/>
|
||||
|
||||
<service
|
||||
android:name=".transport.ConfigurableBackupTransportService"
|
||||
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
|
||||
|
||||
import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
|
||||
import android.app.ActivityManager
|
||||
import android.app.ActivityManager.RunningAppProcessInfo
|
||||
import android.app.Application
|
||||
import android.app.backup.BackupManager
|
||||
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
|
||||
|
@ -19,18 +17,16 @@ import android.os.ServiceManager.getService
|
|||
import android.os.StrictMode
|
||||
import android.os.UserHandle
|
||||
import android.os.UserManager
|
||||
import android.util.Log
|
||||
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||
import androidx.work.WorkManager
|
||||
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.header.headerModule
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
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.restoreUiModule
|
||||
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.worker.AppBackupWorker
|
||||
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.koin.androidContext
|
||||
import org.koin.android.ext.koin.androidLogger
|
||||
|
@ -66,15 +61,7 @@ open class App : Application() {
|
|||
private val appModule = module {
|
||||
single { SettingsManager(this@App) }
|
||||
single { BackupNotificationManager(this@App) }
|
||||
single { BackendManager(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 { StoragePluginManager(this@App, get(), get(), get()) }
|
||||
single { BackupStateManager(this@App) }
|
||||
single { Clock() }
|
||||
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
|
||||
|
@ -85,17 +72,16 @@ open class App : Application() {
|
|||
app = this@App,
|
||||
settingsManager = get(),
|
||||
keyManager = get(),
|
||||
backendManager = get(),
|
||||
pluginManager = get(),
|
||||
metadataManager = get(),
|
||||
appListRetriever = get(),
|
||||
storageBackup = get(),
|
||||
backupManager = get(),
|
||||
backupInitializer = get(),
|
||||
backupStateManager = get(),
|
||||
checker = get(),
|
||||
)
|
||||
}
|
||||
viewModel {
|
||||
RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get(), get())
|
||||
}
|
||||
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
|
||||
viewModel {
|
||||
BackupStorageViewModel(
|
||||
app = this@App,
|
||||
|
@ -105,7 +91,7 @@ open class App : Application() {
|
|||
safHandler = get(),
|
||||
webDavHandler = get(),
|
||||
settingsManager = get(),
|
||||
backendManager = get(),
|
||||
storagePluginManager = get(),
|
||||
)
|
||||
}
|
||||
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
|
||||
|
@ -115,7 +101,6 @@ open class App : Application() {
|
|||
super.onCreate()
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
startKoin()
|
||||
if (!isTest) migrateToOwnScheduling()
|
||||
if (isDebugBuild()) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
|
@ -131,6 +116,10 @@ open class App : Application() {
|
|||
.build()
|
||||
)
|
||||
}
|
||||
permitDiskReads {
|
||||
migrateTokenFromMetadataToSettingsManager()
|
||||
}
|
||||
if (!isTest) migrateToOwnScheduling()
|
||||
}
|
||||
|
||||
protected open fun startKoin() = startKoin {
|
||||
|
@ -149,24 +138,29 @@ open class App : Application() {
|
|||
restoreModule,
|
||||
installModule,
|
||||
storageModule,
|
||||
repoModule,
|
||||
workerModule,
|
||||
restoreUiModule,
|
||||
appModule
|
||||
)
|
||||
|
||||
private val settingsManager: SettingsManager by inject()
|
||||
private val metadataManager: MetadataManager 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()
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
Log.w("Seedvault", "onTrimMemory($level) ${getMemStr()}")
|
||||
val processInfo = RunningAppProcessInfo()
|
||||
ActivityManager.getMyMemoryState(processInfo)
|
||||
Log.w("Seedvault", " lastTrimLevel: ${processInfo.lastTrimLevel}")
|
||||
Log.w("Seedvault", " importance: ${processInfo.importance}")
|
||||
super.onTrimMemory(level)
|
||||
/**
|
||||
* The responsibility for the current token was moved to the [SettingsManager]
|
||||
* in the end of 2020.
|
||||
* This method migrates the token for existing installs and can be removed
|
||||
* after sufficient time has passed.
|
||||
*/
|
||||
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() {
|
||||
if (!backupStateManager.isFrameworkSchedulingEnabled) { // already on own scheduling
|
||||
// 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
|
||||
}
|
||||
|
||||
if (backupManager.currentTransport == TRANSPORT_ID) {
|
||||
backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false)
|
||||
if (backupManager.isBackupEnabled && !backendManager.isOnRemovableDrive) {
|
||||
if (backupManager.isBackupEnabled && !pluginManager.isOnRemovableDrive) {
|
||||
AppBackupWorker.schedule(applicationContext, settingsManager, UPDATE)
|
||||
}
|
||||
// cancel old D2D worker
|
||||
|
@ -197,7 +191,6 @@ const val ANCESTRAL_RECORD_KEY = "@ancestral_record@"
|
|||
const val NO_DATA_END_SENTINEL = "@end@"
|
||||
const val GLOBAL_METADATA_KEY = "@meta@"
|
||||
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
|
||||
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")
|
||||
fun Context.getStorageContext(isUsbStorage: () -> Boolean): Context {
|
||||
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.os.Bundle
|
||||
import android.util.Log
|
||||
import android.util.Log.DEBUG
|
||||
|
||||
private val TAG = BackupMonitor::class.java.name
|
||||
|
||||
open class BackupMonitor : IBackupManagerMonitor.Stub() {
|
||||
class BackupMonitor : IBackupManagerMonitor.Stub() {
|
||||
|
||||
override fun onEvent(bundle: Bundle) {
|
||||
onEvent(
|
||||
id = bundle.getInt(EXTRA_LOG_EVENT_ID),
|
||||
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")
|
||||
val id = bundle.getInt(EXTRA_LOG_EVENT_ID)
|
||||
val packageName = bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME, "?")
|
||||
if (id == LOG_EVENT_ID_ERROR_PREFLIGHT) {
|
||||
val preflightResult = bundle.getLong(EXTRA_LOG_PREFLIGHT_ERROR, -1)
|
||||
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 com.stevesoltys.seedvault.storage.StorageBackupService
|
||||
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.AppCheckerWorker
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
|
@ -33,28 +31,14 @@ class BackupStateManager(
|
|||
flow = ConfigurableBackupTransportService.isRunning,
|
||||
flow2 = StorageBackupService.isRunning,
|
||||
flow3 = workManager.getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME),
|
||||
) { appBackupRunning, filesBackupRunning, workInfo1 ->
|
||||
val workInfoState1 = workInfo1.getOrNull(0)?.state
|
||||
) { appBackupRunning, filesBackupRunning, workInfos ->
|
||||
val workInfoState = workInfos.getOrNull(0)?.state
|
||||
Log.i(
|
||||
TAG, "appBackupRunning: $appBackupRunning, " +
|
||||
"filesBackupRunning: $filesBackupRunning, " +
|
||||
"appBackupWorker: ${workInfoState1?.name}"
|
||||
"workInfoState: ${workInfoState?.name}"
|
||||
)
|
||||
appBackupRunning || filesBackupRunning || workInfoState1 == 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
|
||||
appBackupRunning || filesBackupRunning || workInfoState == RUNNING
|
||||
}
|
||||
|
||||
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.provider.DocumentsContract
|
||||
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.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.worker.BackupRequester.Companion.requestFilesAndAppBackup
|
||||
import com.stevesoltys.seedvault.worker.AppBackupWorker
|
||||
import org.koin.core.context.GlobalContext.get
|
||||
import java.util.Date
|
||||
|
||||
|
@ -33,6 +37,7 @@ class UsbIntentReceiver : UsbMonitor() {
|
|||
|
||||
// using KoinComponent would crash robolectric tests :(
|
||||
private val settingsManager: SettingsManager by lazy { get().get() }
|
||||
private val metadataManager: MetadataManager by lazy { get().get() }
|
||||
private val backupManager: IBackupManager by lazy { get().get() }
|
||||
|
||||
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
|
||||
|
@ -42,15 +47,14 @@ class UsbIntentReceiver : UsbMonitor() {
|
|||
val attachedFlashDrive = FlashDrive.from(device)
|
||||
return if (savedFlashDrive == attachedFlashDrive) {
|
||||
Log.d(TAG, "Matches stored device, checking backup time...")
|
||||
val lastBackupTime = settingsManager.lastBackupTime.value ?: 0
|
||||
val backupMillis = System.currentTimeMillis() - lastBackupTime
|
||||
val backupMillis = System.currentTimeMillis() - metadataManager.getLastBackupTime()
|
||||
if (backupMillis >= settingsManager.backupFrequencyInMillis) {
|
||||
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
|
||||
} else {
|
||||
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
|
||||
}
|
||||
} else {
|
||||
|
@ -60,7 +64,16 @@ class UsbIntentReceiver : UsbMonitor() {
|
|||
}
|
||||
|
||||
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 ||
|
||||
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.")
|
||||
device.log()
|
||||
|
||||
|
|
|
@ -5,33 +5,23 @@
|
|||
|
||||
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.stevesoltys.seedvault.encodeBase64
|
||||
import com.stevesoltys.seedvault.header.HeaderReader
|
||||
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
||||
import com.stevesoltys.seedvault.header.MAX_VERSION_HEADER_SIZE
|
||||
import com.stevesoltys.seedvault.header.SegmentHeader
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.VersionHeader
|
||||
import org.calyxos.seedvault.core.crypto.CoreCrypto
|
||||
import org.calyxos.seedvault.core.crypto.CoreCrypto.ALGORITHM_HMAC
|
||||
import org.calyxos.seedvault.core.crypto.CoreCrypto.deriveKey
|
||||
import org.calyxos.seedvault.core.toByteArrayFromHex
|
||||
import org.calyxos.seedvault.core.toHexString
|
||||
import org.calyxos.backup.storage.crypto.StreamCrypto
|
||||
import org.calyxos.backup.storage.crypto.StreamCrypto.deriveStreamKey
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
|
@ -57,18 +47,13 @@ internal interface Crypto {
|
|||
*/
|
||||
fun getRandomBytes(size: Int): ByteArray
|
||||
|
||||
/**
|
||||
* Returns the ID of the backup repository as a 64 char hex string.
|
||||
*/
|
||||
val repoId: String
|
||||
fun getNameForPackage(salt: String, packageName: String): String
|
||||
|
||||
/**
|
||||
* A secret key of size [KEY_SIZE_BYTES]
|
||||
* only used to create a gear table specific to each main key.
|
||||
* 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
|
||||
*/
|
||||
val gearTableKey: ByteArray
|
||||
|
||||
fun sha256(bytes: ByteArray): ByteArray
|
||||
fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String
|
||||
|
||||
/**
|
||||
* Returns a [AesGcmHkdfStreaming] encrypting stream
|
||||
|
@ -90,29 +75,6 @@ internal interface Crypto {
|
|||
associatedData: ByteArray,
|
||||
): 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]
|
||||
* 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_ICONS: Byte = 0x03
|
||||
|
||||
@SuppressLint("HardwareIds")
|
||||
internal class CryptoImpl(
|
||||
context: Context,
|
||||
private val keyManager: KeyManager,
|
||||
private val cipherFactory: CipherFactory,
|
||||
private val headerReader: HeaderReader,
|
||||
private val androidId: String = Settings.Secure.getString(context.contentResolver, ANDROID_ID),
|
||||
) : Crypto {
|
||||
|
||||
private val keyV1: ByteArray by lazy {
|
||||
deriveKey(keyManager.getMainKey(), "app data key".toByteArray())
|
||||
private val key: ByteArray by lazy {
|
||||
deriveStreamKey(keyManager.getMainKey(), "app data key".toByteArray())
|
||||
}
|
||||
private val streamKey: ByteArray by lazy {
|
||||
deriveKey(keyManager.getMainKey(), "app backup stream key".toByteArray())
|
||||
}
|
||||
private val secureRandom: SecureRandom by lazy { SecureRandom.getInstanceStrong() }
|
||||
private val secureRandom: SecureRandom by lazy { SecureRandom() }
|
||||
|
||||
override fun getRandomBytes(size: Int) = ByteArray(size).apply {
|
||||
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 {
|
||||
return sha256("$salt$packageName".toByteArray()).encodeBase64()
|
||||
}
|
||||
|
||||
@Deprecated("only for v1")
|
||||
override fun getNameForApk(salt: String, packageName: String, suffix: String): String {
|
||||
return sha256("${salt}APK$packageName$suffix".toByteArray()).encodeBase64()
|
||||
}
|
||||
|
||||
override fun sha256(bytes: ByteArray): ByteArray {
|
||||
private fun sha256(bytes: ByteArray): ByteArray {
|
||||
val messageDigest: MessageDigest = try {
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
|
@ -234,12 +155,21 @@ internal class CryptoImpl(
|
|||
return messageDigest.digest()
|
||||
}
|
||||
|
||||
@Deprecated("only for v1")
|
||||
@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,
|
||||
associatedData: ByteArray,
|
||||
): InputStream = CoreCrypto.newDecryptingStream(keyV1, inputStream, associatedData)
|
||||
): InputStream {
|
||||
return StreamCrypto.newDecryptingStream(key, inputStream, associatedData)
|
||||
}
|
||||
|
||||
@Suppress("Deprecation")
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
package com.stevesoltys.seedvault.crypto
|
||||
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
import java.security.KeyStore
|
||||
|
||||
|
@ -21,5 +20,5 @@ val cryptoModule = module {
|
|||
}
|
||||
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_MAIN = "HmacSHA256"
|
||||
|
||||
interface KeyManager : org.calyxos.seedvault.core.crypto.KeyManager {
|
||||
interface KeyManager {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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(
|
||||
|
|
|
@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_FULL
|
|||
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
|
||||
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_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_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.os.Build
|
||||
import com.stevesoltys.seedvault.crypto.TYPE_METADATA
|
||||
import com.stevesoltys.seedvault.encodeBase64
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
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 java.nio.ByteBuffer
|
||||
|
||||
|
@ -30,23 +26,6 @@ data class BackupMetadata(
|
|||
internal var d2dBackup: Boolean = false,
|
||||
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
|
||||
get() = packageMetadataMap.values.sumOf { m ->
|
||||
(m.size ?: 0L) + (m.splits?.sumOf { it.size ?: 0L } ?: 0L)
|
||||
|
@ -112,56 +91,12 @@ data class PackageMetadata(
|
|||
internal val version: Long? = null,
|
||||
internal val installer: String? = 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 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
|
||||
fun hasApk(): Boolean {
|
||||
return version != null && // v2 doesn't use sha256 here
|
||||
(sha256 != null || baseApkChunkIds?.isNotEmpty() == true) &&
|
||||
signatures != null
|
||||
return version != null && sha256 != null && signatures != null
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -169,7 +104,6 @@ data class ApkSplit(
|
|||
val name: String,
|
||||
val size: Long?,
|
||||
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
|
||||
)
|
||||
|
||||
|
|
|
@ -8,11 +8,23 @@ package com.stevesoltys.seedvault.metadata
|
|||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
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 androidx.annotation.VisibleForTesting
|
||||
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.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.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
@ -27,50 +39,130 @@ internal const val METADATA_SALT_SIZE = 32
|
|||
internal class MetadataManager(
|
||||
private val context: Context,
|
||||
private val clock: Clock,
|
||||
private val crypto: Crypto,
|
||||
private val metadataWriter: MetadataWriter,
|
||||
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
|
||||
get() {
|
||||
if (field == uninitializedMetadata) {
|
||||
field = try {
|
||||
val m = getMetadataFromCache() ?: throw IOException()
|
||||
if (m == uninitializedMetadata) m.copy(salt = "initialized")
|
||||
else m
|
||||
getMetadataFromCache() ?: throw IOException()
|
||||
} catch (e: IOException) {
|
||||
// This can happen if the storage location ran out of space
|
||||
// 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:
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
@Throws(IOException::class)
|
||||
fun onPackageBackedUp(
|
||||
packageInfo: PackageInfo,
|
||||
type: BackupType?,
|
||||
type: BackupType,
|
||||
size: Long?,
|
||||
metadataOutputStream: OutputStream,
|
||||
) {
|
||||
val packageName = packageInfo.packageName
|
||||
modifyCachedMetadata {
|
||||
modifyMetadata(metadataOutputStream) {
|
||||
val now = clock.time()
|
||||
metadata.time = now
|
||||
metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
|
||||
metadata.packageMetadataMap.getOrPut(packageName) {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
PackageMetadata(
|
||||
time = now,
|
||||
state = APK_AND_DATA,
|
||||
backupType = type,
|
||||
size = size,
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp &&
|
||||
launchableSystemApps.contains(packageName),
|
||||
)
|
||||
}.apply {
|
||||
time = now
|
||||
|
@ -78,6 +170,10 @@ internal class MetadataManager(
|
|||
backupType = type
|
||||
// don't override a previous K/V size, if there were no K/V changes
|
||||
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(
|
||||
packageInfo: PackageInfo,
|
||||
packageState: PackageState,
|
||||
metadataOutputStream: OutputStream,
|
||||
backupType: BackupType? = null,
|
||||
) {
|
||||
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
|
||||
modifyCachedMetadata {
|
||||
modifyMetadata(metadataOutputStream) {
|
||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
PackageMetadata(
|
||||
time = 0L,
|
||||
state = packageState,
|
||||
backupType = backupType,
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp &&
|
||||
launchableSystemApps.contains(packageInfo.packageName),
|
||||
)
|
||||
}.state = packageState
|
||||
}
|
||||
|
@ -112,6 +213,7 @@ internal class MetadataManager(
|
|||
* Call this for all packages we can not back up for some reason.
|
||||
*
|
||||
* It updates the packages' local metadata.
|
||||
* You still need to call [uploadMetadata] to persist all local modifications.
|
||||
*/
|
||||
@Synchronized
|
||||
@Throws(IOException::class)
|
||||
|
@ -120,10 +222,14 @@ internal class MetadataManager(
|
|||
packageState: PackageState,
|
||||
) = modifyCachedMetadata {
|
||||
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
|
||||
val isSystemApp = packageInfo.isSystemApp()
|
||||
PackageMetadata(
|
||||
time = 0L,
|
||||
state = packageState,
|
||||
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
|
||||
system = isSystemApp,
|
||||
isLaunchableSystemApp = isSystemApp &&
|
||||
launchableSystemApps.contains(packageInfo.packageName),
|
||||
)
|
||||
}.apply {
|
||||
state = packageState
|
||||
|
@ -134,15 +240,18 @@ internal class MetadataManager(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads metadata to given [metadataOutputStream] after performing local modifications.
|
||||
*/
|
||||
@Synchronized
|
||||
fun getPackageMetadata(packageName: String): PackageMetadata? {
|
||||
return metadata.packageMetadataMap[packageName]?.copy()
|
||||
@Throws(IOException::class)
|
||||
fun uploadMetadata(metadataOutputStream: OutputStream) {
|
||||
metadataWriter.write(metadata, metadataOutputStream)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun modifyCachedMetadata(modFun: () -> Unit) {
|
||||
val oldMetadata = metadata.copy(
|
||||
// copy map, otherwise it will re-use same reference
|
||||
val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
|
||||
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
||||
)
|
||||
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
|
||||
@VisibleForTesting
|
||||
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
|
||||
|
||||
val metadataModule = module {
|
||||
single { MetadataManager(androidContext(), get(), get(), get()) }
|
||||
single<MetadataWriter> { MetadataWriterImpl() }
|
||||
single { MetadataManager(androidContext(), get(), get(), get(), get(), get(), get()) }
|
||||
single<MetadataWriter> { MetadataWriterImpl(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)
|
||||
|
||||
val metadataBytes = try {
|
||||
crypto.newDecryptingStreamV1(inputStream, getAD(version, expectedToken)).readBytes()
|
||||
crypto.newDecryptingStream(inputStream, getAD(version, expectedToken)).readBytes()
|
||||
} catch (e: GeneralSecurityException) {
|
||||
throw DecryptionFailedException(e)
|
||||
}
|
||||
|
@ -94,14 +94,14 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
|||
val json = JSONObject(bytes.toString(Utf8))
|
||||
// get backup metadata and check expectations
|
||||
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) {
|
||||
throw SecurityException(
|
||||
"Invalid version '${version.toInt()}' in metadata," +
|
||||
"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(
|
||||
"Invalid token '$token' in metadata, expected '$expectedToken'."
|
||||
)
|
||||
|
@ -157,11 +157,11 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
|||
return BackupMetadata(
|
||||
version = version,
|
||||
token = token,
|
||||
salt = if (version == 0.toByte()) "" else meta.optString(JSON_METADATA_SALT, ""),
|
||||
time = meta.optLong(JSON_METADATA_TIME, -1),
|
||||
androidVersion = meta.optInt(JSON_METADATA_SDK_INT, 0),
|
||||
androidIncremental = meta.optString(JSON_METADATA_INCREMENTAL),
|
||||
deviceName = meta.optString(JSON_METADATA_NAME),
|
||||
salt = if (version == 0.toByte()) "" else meta.getString(JSON_METADATA_SALT),
|
||||
time = meta.getLong(JSON_METADATA_TIME),
|
||||
androidVersion = meta.getInt(JSON_METADATA_SDK_INT),
|
||||
androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL),
|
||||
deviceName = meta.getString(JSON_METADATA_NAME),
|
||||
d2dBackup = meta.optBoolean(JSON_METADATA_D2D_BACKUP, false),
|
||||
packageMetadataMap = packageMetadataMap,
|
||||
)
|
||||
|
|
|
@ -6,18 +6,42 @@
|
|||
package com.stevesoltys.seedvault.metadata
|
||||
|
||||
import com.stevesoltys.seedvault.Utf8
|
||||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
||||
interface MetadataWriter {
|
||||
@Throws(IOException::class)
|
||||
fun write(metadata: BackupMetadata, outputStream: OutputStream)
|
||||
|
||||
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 {
|
||||
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) {
|
||||
json.put(packageName, JSONObject().apply {
|
||||
|
@ -33,8 +57,31 @@ internal class MetadataWriterImpl : MetadataWriter {
|
|||
if (packageMetadata.size != null) {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.backend
|
||||
package com.stevesoltys.seedvault.plugins
|
||||
|
||||
import android.content.pm.PackageInfo
|
||||
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
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.backend
|
||||
package com.stevesoltys.seedvault.plugins
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.getStorageContext
|
||||
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.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 settingsManager: SettingsManager,
|
||||
private val blobCache: BlobCache,
|
||||
backendFactory: BackendFactory,
|
||||
safFactory: SafFactory,
|
||||
webDavFactory: WebDavFactory,
|
||||
) {
|
||||
|
||||
@Volatile
|
||||
private var mBackend: Backend?
|
||||
private var mAppPlugin: StoragePlugin<*>?
|
||||
private var mFilesPlugin: org.calyxos.backup.storage.api.StoragePlugin?
|
||||
private var mStorageProperties: StorageProperties<*>?
|
||||
|
||||
@Volatile
|
||||
private var mBackendProperties: BackendProperties<*>?
|
||||
|
||||
val backend: Backend
|
||||
val appPlugin: StoragePlugin<*>
|
||||
@Synchronized
|
||||
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
|
||||
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 {
|
||||
when (settingsManager.storagePluginType) {
|
||||
StoragePluginType.SAF -> {
|
||||
val safConfig = settingsManager.getSafProperties() ?: error("No SAF storage saved")
|
||||
mBackend = backendFactory.createSafBackend(safConfig)
|
||||
mBackendProperties = safConfig
|
||||
val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage saved")
|
||||
val documentsStorage = DocumentsStorage(context, settingsManager, safStorage)
|
||||
mAppPlugin = safFactory.createAppStoragePlugin(safStorage, documentsStorage)
|
||||
mFilesPlugin = safFactory.createFilesStoragePlugin(safStorage, documentsStorage)
|
||||
mStorageProperties = safStorage
|
||||
}
|
||||
|
||||
StoragePluginType.WEB_DAV -> {
|
||||
val webDavProperties =
|
||||
settingsManager.webDavProperties ?: error("No WebDAV config saved")
|
||||
mBackend = backendFactory.createWebDavBackend(webDavProperties.config)
|
||||
mBackendProperties = webDavProperties
|
||||
mAppPlugin = webDavFactory.createAppStoragePlugin(webDavProperties.config)
|
||||
mFilesPlugin = webDavFactory.createFilesStoragePlugin(webDavProperties.config)
|
||||
mStorageProperties = webDavProperties
|
||||
}
|
||||
|
||||
null -> {
|
||||
mBackend = null
|
||||
mBackendProperties = null
|
||||
mAppPlugin = null
|
||||
mFilesPlugin = null
|
||||
mStorageProperties = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isValidAppPluginSet(): Boolean {
|
||||
if (mBackend == null) return false
|
||||
if (mBackend is SafBackend) {
|
||||
val storage = settingsManager.getSafProperties() ?: return false
|
||||
if (mAppPlugin == null || mFilesPlugin == null) return false
|
||||
if (mAppPlugin is DocumentsProviderStoragePlugin) {
|
||||
val storage = settingsManager.getSafStorage() ?: return false
|
||||
if (storage.isUsb) return true
|
||||
return permitDiskReads {
|
||||
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,
|
||||
* e.g. while backup/restore operation is still running.
|
||||
*/
|
||||
@WorkerThread
|
||||
@Synchronized
|
||||
fun <T> changePlugins(
|
||||
backend: Backend,
|
||||
storageProperties: BackendProperties<T>,
|
||||
storageProperties: StorageProperties<T>,
|
||||
appPlugin: StoragePlugin<T>,
|
||||
filesPlugin: org.calyxos.backup.storage.api.StoragePlugin,
|
||||
) {
|
||||
settingsManager.setStorageBackend(backend)
|
||||
mBackend = backend
|
||||
mBackendProperties = storageProperties
|
||||
blobCache.clearLocalCache()
|
||||
// TODO not critical, but nice to have: clear also local snapshot cache
|
||||
settingsManager.setStoragePlugin(appPlugin)
|
||||
mStorageProperties = storageProperties
|
||||
mAppPlugin = appPlugin
|
||||
mFilesPlugin = filesPlugin
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -108,7 +112,7 @@ class BackendManager(
|
|||
*/
|
||||
@WorkerThread
|
||||
fun canDoBackupNow(): Boolean {
|
||||
val storage = backendProperties ?: return false
|
||||
val storage = storageProperties ?: return false
|
||||
return !isOnUnavailableUsb() &&
|
||||
!storage.isUnavailableNetwork(context, settingsManager.useMeteredNetwork)
|
||||
}
|
||||
|
@ -123,7 +127,7 @@ class BackendManager(
|
|||
*/
|
||||
@WorkerThread
|
||||
fun isOnUnavailableUsb(): Boolean {
|
||||
val storage = backendProperties ?: return false
|
||||
val storage = storageProperties ?: return false
|
||||
val systemContext = context.getStorageContext { storage.isUsb }
|
||||
return storage.isUnavailableUsb(systemContext)
|
||||
}
|
||||
|
@ -134,7 +138,7 @@ class BackendManager(
|
|||
@WorkerThread
|
||||
suspend fun getFreeSpace(): Long? {
|
||||
return try {
|
||||
backend.getFreeSpace()
|
||||
appPlugin.getFreeSpace()
|
||||
} catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm
|
||||
Log.e("StoragePluginManager", "Error getting free space: ", e)
|
||||
null
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.calyxos.seedvault.core.backends
|
||||
package com.stevesoltys.seedvault.plugins
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
|
@ -12,20 +12,20 @@ import androidx.annotation.WorkerThread
|
|||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
public abstract class BackendProperties<T> {
|
||||
public abstract val config: T
|
||||
public abstract val name: String
|
||||
public abstract val isUsb: Boolean
|
||||
public abstract val requiresNetwork: Boolean
|
||||
abstract class StorageProperties<T> {
|
||||
abstract val config: T
|
||||
abstract val name: String
|
||||
abstract val isUsb: Boolean
|
||||
abstract val requiresNetwork: Boolean
|
||||
|
||||
@WorkerThread
|
||||
public abstract fun isUnavailableUsb(context: Context): Boolean
|
||||
abstract fun isUnavailableUsb(context: Context): Boolean
|
||||
|
||||
/**
|
||||
* Returns true if this is storage that requires network access,
|
||||
* 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)
|
||||
}
|
||||
|
||||
|
@ -37,7 +37,7 @@ public abstract class BackendProperties<T> {
|
|||
}
|
||||
}
|
||||
|
||||
public fun Exception.isOutOfSpace(): Boolean {
|
||||
fun Exception.isOutOfSpace(): Boolean {
|
||||
return when (this) {
|
||||
is IOException -> message?.contains("No space left on device") == true ||
|
||||
(cause as? HttpException)?.code == 507
|
|
@ -3,13 +3,13 @@
|
|||
* 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.pm.PackageInfo
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
|
@ -3,14 +3,15 @@
|
|||
* 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 org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val storagePluginModuleSaf = module {
|
||||
single { SafFactory(androidContext(), get(), get()) }
|
||||
single { SafHandler(androidContext(), get(), get(), get()) }
|
||||
|
||||
@Suppress("Deprecation")
|
||||
|
@ -18,9 +19,8 @@ val storagePluginModuleSaf = module {
|
|||
DocumentsProviderLegacyPlugin(
|
||||
context = androidContext(),
|
||||
storageGetter = {
|
||||
val safProperties = get<SettingsManager>().getSafProperties()
|
||||
?: error("No SAF storage")
|
||||
DocumentsStorage(androidContext(), safProperties)
|
||||
val safStorage = get<SettingsManager>().getSafStorage() ?: error("No SAF storage")
|
||||
DocumentsStorage(androidContext(), get(), safStorage)
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.backend.saf
|
||||
package com.stevesoltys.seedvault.plugins.saf
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
|
@ -20,28 +20,33 @@ import android.util.Log
|
|||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.stevesoltys.seedvault.getStorageContext
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
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.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
|
||||
|
||||
@Deprecated("")
|
||||
const val DIRECTORY_FULL_BACKUP = "full"
|
||||
|
||||
@Deprecated("")
|
||||
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
|
||||
|
||||
internal class DocumentsStorage(
|
||||
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 contentResolver: ContentResolver get() = context.contentResolver
|
||||
|
||||
private var rootBackupDir: DocumentFile? = null
|
||||
internal var rootBackupDir: DocumentFile? = null
|
||||
get() = runBlocking {
|
||||
if (field == null) {
|
||||
val parent = safStorage.getDocumentFile(context)
|
||||
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) {
|
||||
Log.e(TAG, "Error creating root backup dir.", e)
|
||||
null
|
||||
|
@ -64,8 +73,41 @@ internal class DocumentsStorage(
|
|||
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)
|
||||
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())
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
suspend fun DocumentFile.deleteContents(context: Context) {
|
||||
for (file in listFilesBlocking(context)) file.delete()
|
||||
}
|
||||
|
||||
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
|
||||
if (name != packageInfo.packageName) {
|
||||
throw AssertionError("Expected ${packageInfo.packageName}, but got $name")
|
||||
|
@ -140,6 +224,26 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile>
|
|||
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.
|
||||
*
|
||||
|
@ -181,7 +285,7 @@ suspend fun DocumentFile.findFileBlocking(context: Context, displayName: String)
|
|||
@Throws(IOException::class, TimeoutCancellationException::class)
|
||||
internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) =
|
||||
withTimeout(timeout) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
suspendCancellableCoroutine<Cursor> { cont ->
|
||||
val cursor = query() ?: throw IOException()
|
||||
cont.invokeOnCancellation { cursor.close() }
|
||||
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
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.backend.saf
|
||||
package com.stevesoltys.seedvault.plugins.saf
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.USB_SERVICE
|
||||
|
@ -14,41 +14,33 @@ import android.net.Uri
|
|||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.isMassStorage
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.settings.FlashDrive
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
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
|
||||
|
||||
private const val TAG = "SafHandler"
|
||||
|
||||
internal class SafHandler(
|
||||
private val context: Context,
|
||||
private val backendFactory: BackendFactory,
|
||||
private val safFactory: SafFactory,
|
||||
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
|
||||
val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
|
||||
return SafProperties(
|
||||
config = uri,
|
||||
name = if (safOption.isInternal()) {
|
||||
val brackets = context.getString(R.string.settings_backup_location_internal)
|
||||
"${safOption.title} ($brackets)"
|
||||
} else {
|
||||
safOption.title
|
||||
},
|
||||
isUsb = safOption.isUsb,
|
||||
requiresNetwork = safOption.requiresNetwork,
|
||||
rootId = safOption.rootId,
|
||||
)
|
||||
val name = if (safOption.isInternal()) {
|
||||
"${safOption.title} (${context.getString(R.string.settings_backup_location_internal)})"
|
||||
} else {
|
||||
safOption.title
|
||||
}
|
||||
return SafStorage(uri, name, safOption.isUsb, safOption.requiresNetwork, safOption.rootId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -57,15 +49,17 @@ internal class SafHandler(
|
|||
*/
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun hasAppBackup(safProperties: SafProperties): Boolean {
|
||||
val backend = backendFactory.createSafBackend(safProperties)
|
||||
return backend.getAvailableBackupFileHandles().isNotEmpty()
|
||||
suspend fun hasAppBackup(safStorage: SafStorage): Boolean {
|
||||
val storage = DocumentsStorage(context, settingsManager, safStorage)
|
||||
val appPlugin = safFactory.createAppStoragePlugin(safStorage, storage)
|
||||
val backups = appPlugin.getAvailableBackups()
|
||||
return backups != null && backups.iterator().hasNext()
|
||||
}
|
||||
|
||||
fun save(safProperties: SafProperties) {
|
||||
settingsManager.setSafProperties(safProperties)
|
||||
fun save(safStorage: SafStorage) {
|
||||
settingsManager.setSafStorage(safStorage)
|
||||
|
||||
if (safProperties.isUsb) {
|
||||
if (safStorage.isUsb) {
|
||||
Log.d(TAG, "Selected storage is a removable USB device.")
|
||||
val wasSaved = saveUsbDevice()
|
||||
// reset stored flash drive, if we did not update it
|
||||
|
@ -73,7 +67,7 @@ internal class SafHandler(
|
|||
} else {
|
||||
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 {
|
||||
|
@ -90,11 +84,12 @@ internal class SafHandler(
|
|||
return false
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun setPlugin(safProperties: SafProperties) {
|
||||
backendManager.changePlugins(
|
||||
backend = backendFactory.createSafBackend(safProperties),
|
||||
storageProperties = safProperties,
|
||||
fun setPlugin(safStorage: SafStorage) {
|
||||
val storage = DocumentsStorage(context, settingsManager, safStorage)
|
||||
storagePluginManager.changePlugins(
|
||||
storageProperties = safStorage,
|
||||
appPlugin = safFactory.createAppStoragePlugin(safStorage, storage),
|
||||
filesPlugin = safFactory.createFilesStoragePlugin(safStorage, storage),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -3,16 +3,16 @@
|
|||
* 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.net.Uri
|
||||
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
|
||||
import androidx.annotation.WorkerThread
|
||||
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 name: String,
|
||||
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.
|
||||
*/
|
||||
val rootId: String?,
|
||||
) : BackendProperties<Uri>() {
|
||||
) : StorageProperties<Uri>() {
|
||||
|
||||
public val uri: Uri = config
|
||||
val uri: Uri = config
|
||||
|
||||
public fun getDocumentFile(context: Context): DocumentFile =
|
||||
DocumentFile.fromTreeUri(context, config)
|
||||
?: throw AssertionError("Should only happen on API < 21.")
|
||||
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, config)
|
||||
?: throw AssertionError("Should only happen on API < 21.")
|
||||
|
||||
/**
|
||||
* Returns true if this is USB storage that is not available, false otherwise.
|
|
@ -3,7 +3,7 @@
|
|||
* 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.Intent
|
||||
|
@ -14,7 +14,7 @@ import android.provider.DocumentsContract
|
|||
import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
||||
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
||||
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_NEXTCLOUD
|
||||
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC
|
|
@ -3,7 +3,7 @@
|
|||
* 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.content.Context
|
||||
|
@ -139,6 +139,17 @@ internal object StorageRootResolver {
|
|||
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? {
|
||||
return getPackageIcon(context, authority, icon) ?: when {
|
||||
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
|
|
@ -3,9 +3,9 @@
|
|||
* 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 username: 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
|
||||
*/
|
||||
|
||||
package com.stevesoltys.seedvault.backend.webdav
|
||||
package com.stevesoltys.seedvault.plugins.webdav
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.calyxos.seedvault.core.backends.Backend
|
||||
import org.calyxos.seedvault.core.backends.BackendFactory
|
||||
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import java.io.IOException
|
||||
|
||||
internal sealed interface WebDavConfigState {
|
||||
|
@ -24,7 +21,7 @@ internal sealed interface WebDavConfigState {
|
|||
object Checking : WebDavConfigState
|
||||
class Success(
|
||||
val properties: WebDavProperties,
|
||||
val backend: Backend,
|
||||
val plugin: WebDavStoragePlugin,
|
||||
) : WebDavConfigState
|
||||
|
||||
class Error(val e: Exception?) : WebDavConfigState
|
||||
|
@ -34,14 +31,14 @@ private val TAG = WebDavHandler::class.java.simpleName
|
|||
|
||||
internal class WebDavHandler(
|
||||
private val context: Context,
|
||||
private val backendFactory: BackendFactory,
|
||||
private val webDavFactory: WebDavFactory,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val backendManager: BackendManager,
|
||||
private val storagePluginManager: StoragePluginManager,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties {
|
||||
val host = config.url.removePrefix("https://")
|
||||
val host = config.url.toHttpUrl().host
|
||||
return WebDavProperties(
|
||||
config = config,
|
||||
name = context.getString(R.string.storage_webdav_name, host),
|
||||
|
@ -54,11 +51,11 @@ internal class WebDavHandler(
|
|||
|
||||
suspend fun onConfigReceived(config: WebDavConfig) {
|
||||
mConfigState.value = WebDavConfigState.Checking
|
||||
val backend = backendFactory.createWebDavBackend(config)
|
||||
val plugin = webDavFactory.createAppStoragePlugin(config) as WebDavStoragePlugin
|
||||
try {
|
||||
if (backend.test()) {
|
||||
if (plugin.test()) {
|
||||
val properties = createWebDavProperties(context, config)
|
||||
mConfigState.value = WebDavConfigState.Success(properties, backend)
|
||||
mConfigState.value = WebDavConfigState.Success(properties, plugin)
|
||||
} else {
|
||||
mConfigState.value = WebDavConfigState.Error(null)
|
||||
}
|
||||
|
@ -78,19 +75,20 @@ internal class WebDavHandler(
|
|||
*/
|
||||
@WorkerThread
|
||||
@Throws(IOException::class)
|
||||
suspend fun hasAppBackup(backend: Backend): Boolean {
|
||||
return backend.getAvailableBackupFileHandles().isNotEmpty()
|
||||
suspend fun hasAppBackup(appPlugin: WebDavStoragePlugin): Boolean {
|
||||
val backups = appPlugin.getAvailableBackups()
|
||||
return backups != null && backups.iterator().hasNext()
|
||||
}
|
||||
|
||||
fun save(properties: WebDavProperties) {
|
||||
settingsManager.saveWebDavConfig(properties.config)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun setPlugin(properties: WebDavProperties, backend: Backend) {
|
||||
backendManager.changePlugins(
|
||||
backend = backend,
|
||||
fun setPlugin(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
|
||||
storagePluginManager.changePlugins(
|
||||
storageProperties = properties,
|
||||
appPlugin = plugin,
|
||||
filesPlugin = webDavFactory.createFilesStoragePlugin(properties.config),
|
||||
)
|
||||
}
|
||||
|
|
@ -3,11 +3,12 @@
|
|||
* 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.dsl.module
|
||||
|
||||
val storagePluginModuleWebDav = module {
|
||||
single { WebDavFactory(androidContext(), get()) }
|
||||
single { WebDavHandler(androidContext(), get(), get(), get()) }
|
||||
}
|
|
@ -3,15 +3,15 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.calyxos.seedvault.core.backends.webdav
|
||||
package com.stevesoltys.seedvault.plugins.webdav
|
||||
|
||||
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 name: String,
|
||||
) : BackendProperties<WebDavConfig>() {
|
||||
) : StorageProperties<WebDavConfig>() {
|
||||
override val isUsb: Boolean = false
|
||||
override val requiresNetwork: Boolean = true
|
||||
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.NO_DATA_END_SENTINEL
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.metadata.PackageState
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
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.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState
|
||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
|
||||
|
@ -54,8 +54,9 @@ internal data class AppRestoreResult(
|
|||
internal class AppDataRestoreManager(
|
||||
private val context: Context,
|
||||
private val backupManager: IBackupManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val restoreCoordinator: RestoreCoordinator,
|
||||
private val backendManager: BackendManager,
|
||||
private val storagePluginManager: StoragePluginManager,
|
||||
) {
|
||||
|
||||
private var session: IRestoreSession? = null
|
||||
|
@ -83,6 +84,12 @@ internal class AppDataRestoreManager(
|
|||
|
||||
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
|
||||
val session = try {
|
||||
getOrStartSession()
|
||||
|
@ -94,7 +101,7 @@ internal class AppDataRestoreManager(
|
|||
return
|
||||
}
|
||||
|
||||
val providerPackageName = backendManager.backend.providerPackageName
|
||||
val providerPackageName = storagePluginManager.appPlugin.providerPackageName
|
||||
val observer = RestoreObserver(
|
||||
restoreCoordinator = restoreCoordinator,
|
||||
restorableBackup = restorableBackup,
|
||||
|
@ -210,7 +217,7 @@ internal class AppDataRestoreManager(
|
|||
context.stopService(foregroundServiceIntent)
|
||||
}
|
||||
|
||||
private fun closeSession() {
|
||||
fun closeSession() {
|
||||
session?.endRestoreSession()
|
||||
session = null
|
||||
}
|
||||
|
@ -256,20 +263,20 @@ internal class AppDataRestoreManager(
|
|||
/**
|
||||
* Restore the next chunk of packages.
|
||||
*
|
||||
* We need to restore packages in chunks, otherwise [BackupTransport.startRestore] in the
|
||||
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder transaction,
|
||||
* causing the entire restoration to fail due to too many package names.
|
||||
* We need to restore in chunks, otherwise [BackupTransport.startRestore] in the
|
||||
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder
|
||||
* transaction, causing the entire restoration to fail.
|
||||
*/
|
||||
private fun restoreNextPackages() {
|
||||
// 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 packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
|
||||
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)
|
||||
|
||||
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
|
||||
|
@ -310,7 +317,6 @@ internal class AppDataRestoreManager(
|
|||
*/
|
||||
override fun restoreFinished(result: Int) {
|
||||
val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
|
||||
Log.d(TAG, "restoreFinished($result) with chunkIndex=$chunkIndex")
|
||||
chunkResults[chunkIndex] = result
|
||||
|
||||
// Restore next chunk if successful and there are more packages to restore.
|
||||
|
@ -319,7 +325,6 @@ internal class AppDataRestoreManager(
|
|||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "onRestoreComplete()")
|
||||
// Restore finished, time to get the result.
|
||||
onRestoreComplete(getRestoreResult(), restorableBackup)
|
||||
closeSession()
|
||||
|
|
|
@ -13,11 +13,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.checkbox.MaterialCheckBox
|
||||
import com.stevesoltys.seedvault.R
|
||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class AppSelectionFragment : Fragment() {
|
||||
|
||||
private val viewModel: RestoreViewModel by activityViewModel()
|
||||
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||
|
||||
private val layoutManager = LinearLayoutManager(context)
|
||||
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.NO_DATA_END_SENTINEL
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
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.systemData
|
||||
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
|
||||
import com.stevesoltys.seedvault.worker.IconManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
@ -25,7 +25,6 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||
import java.util.Locale
|
||||
|
||||
internal class SelectedAppsState(
|
||||
|
@ -38,7 +37,7 @@ private val TAG = AppSelectionManager::class.simpleName
|
|||
|
||||
internal class AppSelectionManager(
|
||||
private val context: Context,
|
||||
private val backendManager: BackendManager,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
private val iconManager: IconManager,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val workDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
|
@ -69,41 +68,31 @@ internal class AppSelectionManager(
|
|||
val name = context.getString(data.nameRes)
|
||||
SelectableAppItem(packageName, metadata.copy(name = name), true)
|
||||
}
|
||||
if (restorableBackup.packageMetadataMap.isNotEmpty()) {
|
||||
val systemItem = SelectableAppItem(
|
||||
packageName = PACKAGE_NAME_SYSTEM,
|
||||
metadata = PackageMetadata(
|
||||
time = restorableBackup.packageMetadataMap.values.maxOf {
|
||||
if (it.system) it.time else -1
|
||||
},
|
||||
size = restorableBackup.packageMetadataMap.values.sumOf {
|
||||
if (it.system) it.size ?: 0L else 0L
|
||||
},
|
||||
system = true,
|
||||
name = context.getString(R.string.backup_system_apps),
|
||||
),
|
||||
selected = isSetupWizard,
|
||||
)
|
||||
items.add(0, systemItem)
|
||||
}
|
||||
val systemItem = SelectableAppItem(
|
||||
packageName = PACKAGE_NAME_SYSTEM,
|
||||
metadata = PackageMetadata(
|
||||
time = restorableBackup.packageMetadataMap.values.maxOf {
|
||||
if (it.system) it.time else -1
|
||||
},
|
||||
size = restorableBackup.packageMetadataMap.values.sumOf {
|
||||
if (it.system) it.size ?: 0L else 0L
|
||||
},
|
||||
system = true,
|
||||
name = context.getString(R.string.backup_system_apps),
|
||||
),
|
||||
selected = isSetupWizard,
|
||||
)
|
||||
items.add(0, systemItem)
|
||||
items.addAll(0, systemDataItems)
|
||||
selectedApps.value =
|
||||
SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false)
|
||||
// download icons
|
||||
coroutineScope.launch(workDispatcher) {
|
||||
val plugin = pluginManager.appPlugin
|
||||
val token = restorableBackup.token
|
||||
val packagesWithIcons = try {
|
||||
if (restorableBackup.version == 1.toByte()) {
|
||||
val backend = backendManager.backend
|
||||
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()
|
||||
plugin.getInputStream(token, FILE_BACKUP_ICONS).use {
|
||||
iconManager.downloadIcons(restorableBackup.version, token, it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading icons:", e)
|
||||
|
|
|
@ -14,11 +14,11 @@ import android.widget.Button
|
|||
import com.stevesoltys.seedvault.R
|
||||
import org.calyxos.backup.storage.ui.restore.FileSelectionFragment
|
||||
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() {
|
||||
|
||||
override val viewModel: RestoreViewModel by activityViewModel()
|
||||
override val viewModel: RestoreViewModel by sharedViewModel()
|
||||
private lateinit var button: Button
|
||||
|
||||
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 androidx.annotation.CallSuper
|
||||
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_BACKUP
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
||||
|
@ -36,7 +35,6 @@ class RestoreActivity : RequireProvisioningActivity() {
|
|||
SELECT_APPS -> showFragment(AppSelectionFragment())
|
||||
RESTORE_APPS -> showFragment(InstallProgressFragment())
|
||||
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
|
||||
RECYCLE_BACKUP -> showFragment(RecycleBackupFragment())
|
||||
RESTORE_FILES -> showFragment(RestoreFilesFragment())
|
||||
RESTORE_SELECT_FILES -> showFragment(FilesSelectionFragment(), true)
|
||||
RESTORE_FILES_STARTED -> {
|
||||
|
|
|
@ -17,10 +17,10 @@ import androidx.fragment.app.Fragment
|
|||
import com.stevesoltys.seedvault.R
|
||||
import org.calyxos.backup.storage.api.SnapshotItem
|
||||
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() {
|
||||
override val viewModel: RestoreViewModel by activityViewModel()
|
||||
override val viewModel: RestoreViewModel by sharedViewModel()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
|
|
|
@ -22,11 +22,11 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.stevesoltys.seedvault.R
|
||||
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() {
|
||||
|
||||
private val viewModel: RestoreViewModel by activityViewModel()
|
||||
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||
|
||||
private val layoutManager = LinearLayoutManager(context)
|
||||
private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon)
|
||||
|
|
|
@ -39,8 +39,8 @@ class RestoreService : Service() {
|
|||
|
||||
override fun onDestroy() {
|
||||
Log.i(TAG, "onDestroy")
|
||||
nm.cancelRestoreNotification()
|
||||
super.onDestroy()
|
||||
nm.cancelRestoreNotification()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -6,25 +6,22 @@
|
|||
package com.stevesoltys.seedvault.restore
|
||||
|
||||
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.Formatter.formatShortFileSize
|
||||
import android.text.format.Formatter
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.View.VISIBLE
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
|
||||
internal class RestoreSetAdapter(
|
||||
private val listener: RestorableBackupClickListener?,
|
||||
private val listener: RestorableBackupClickListener,
|
||||
private val items: List<RestorableBackup>,
|
||||
) : Adapter<RestoreSetViewHolder>() {
|
||||
|
||||
|
@ -42,57 +39,33 @@ internal class RestoreSetAdapter(
|
|||
|
||||
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 appView = v.requireViewById<TextView>(R.id.appView)
|
||||
private val apkView = v.requireViewById<TextView>(R.id.apkView)
|
||||
private val timeView = v.requireViewById<TextView>(R.id.timeView)
|
||||
private val subtitleView = v.requireViewById<TextView>(R.id.subtitleView)
|
||||
private val sizeView = v.requireViewById<TextView>(R.id.sizeView)
|
||||
|
||||
internal fun bind(item: RestorableBackup) {
|
||||
if (listener != null) {
|
||||
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
|
||||
}
|
||||
if (item.canBeRestored) {
|
||||
imageView.setImageResource(R.drawable.ic_phone_android)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.ic_error_red)
|
||||
}
|
||||
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
|
||||
titleView.text = item.name
|
||||
|
||||
appView.text = if (item.sizeAppData > 0) {
|
||||
v.context.getString(
|
||||
R.string.restore_restore_set_apps,
|
||||
item.numAppData,
|
||||
formatShortFileSize(v.context, item.sizeAppData),
|
||||
val lastBackup = getRelativeTime(item.time)
|
||||
val setup = getRelativeTime(item.token)
|
||||
subtitleView.text =
|
||||
v.context.getString(R.string.restore_restore_set_times, lastBackup, setup)
|
||||
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 {
|
||||
v.context.getString(R.string.restore_restore_set_apps_no_size, item.numAppData)
|
||||
sizeView.visibility = VISIBLE
|
||||
}
|
||||
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 {
|
||||
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.recyclerview.widget.RecyclerView
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
class RestoreSetFragment : Fragment() {
|
||||
|
||||
private val viewModel: RestoreViewModel by activityViewModel()
|
||||
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||
|
||||
private lateinit var listView: RecyclerView
|
||||
private lateinit var progressBar: ProgressBar
|
||||
|
|
|
@ -19,12 +19,11 @@ val restoreUiModule = module {
|
|||
settingsManager = get(),
|
||||
keyManager = get(),
|
||||
backupManager = get(),
|
||||
appBackupManager = get(),
|
||||
restoreCoordinator = get(),
|
||||
apkRestore = get(),
|
||||
iconManager = get(),
|
||||
storageBackup = get(),
|
||||
backendManager = get(),
|
||||
pluginManager = get(),
|
||||
fileSelectionManager = get(),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -17,10 +17,8 @@ import androidx.lifecycle.MutableLiveData
|
|||
import androidx.lifecycle.asLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.backend.BackendManager
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
|
||||
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.settings.SettingsManager
|
||||
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.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
|
@ -67,23 +62,22 @@ internal class RestoreViewModel(
|
|||
keyManager: KeyManager,
|
||||
backupManager: IBackupManager,
|
||||
private val restoreCoordinator: RestoreCoordinator,
|
||||
private val appBackupManager: AppBackupManager,
|
||||
private val apkRestore: ApkRestore,
|
||||
private val iconManager: IconManager,
|
||||
storageBackup: StorageBackup,
|
||||
backendManager: BackendManager,
|
||||
pluginManager: StoragePluginManager,
|
||||
override val fileSelectionManager: FileSelectionManager,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager, backendManager),
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager),
|
||||
RestorableBackupClickListener, SnapshotViewModel {
|
||||
|
||||
override val isRestoreOperation = true
|
||||
var isSetupWizard = false
|
||||
|
||||
private val appSelectionManager =
|
||||
AppSelectionManager(app, backendManager, iconManager, viewModelScope)
|
||||
AppSelectionManager(app, pluginManager, iconManager, viewModelScope)
|
||||
private val appDataRestoreManager = AppDataRestoreManager(
|
||||
app, backupManager, restoreCoordinator, backendManager
|
||||
app, backupManager, settingsManager, restoreCoordinator, pluginManager
|
||||
)
|
||||
|
||||
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
|
||||
|
@ -112,11 +106,20 @@ internal class RestoreViewModel(
|
|||
private var storedSnapshot: StoredSnapshot? = null
|
||||
|
||||
internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
|
||||
val result = when (val backups = restoreCoordinator.getAvailableBackups()) {
|
||||
is ErrorResult -> RestoreSetResult(
|
||||
app.getString(R.string.restore_set_error) + "\n\n${backups.e}"
|
||||
)
|
||||
is SuccessResult -> RestoreSetResult(backups.backups)
|
||||
val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->
|
||||
when (metadata.time) {
|
||||
0L -> {
|
||||
Log.d(TAG, "Ignoring RestoreSet with no last backup time: $token.")
|
||||
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)
|
||||
}
|
||||
|
@ -173,28 +176,11 @@ internal class RestoreViewModel(
|
|||
super.onCleared()
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(ioDispatcher) { iconManager.removeIcons() }
|
||||
appDataRestoreManager.closeSession()
|
||||
}
|
||||
|
||||
@UiThread
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -239,7 +225,6 @@ internal enum class DisplayFragment {
|
|||
SELECT_APPS,
|
||||
RESTORE_APPS,
|
||||
RESTORE_BACKUP,
|
||||
RECYCLE_BACKUP,
|
||||
RESTORE_FILES,
|
||||
RESTORE_SELECT_FILES,
|
||||
RESTORE_FILES_STARTED,
|
||||
|
|
|
@ -11,19 +11,16 @@ import android.content.Intent
|
|||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.GET_SIGNATURES
|
||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||
import android.content.pm.SigningInfo
|
||||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.BackupStateManager
|
||||
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.encodeBase64
|
||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.repo.Loader
|
||||
import com.stevesoltys.seedvault.repo.getBlobHandles
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
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.install.ApkInstallState.FAILED
|
||||
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.SUCCEEDED
|
||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.worker.hashSignature
|
||||
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
|
||||
import com.stevesoltys.seedvault.worker.getSignatures
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
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.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.security.GeneralSecurityException
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
private val TAG = ApkRestore::class.java.simpleName
|
||||
|
@ -53,8 +44,7 @@ internal class ApkRestore(
|
|||
private val context: Context,
|
||||
private val backupManager: IBackupManager,
|
||||
private val backupStateManager: BackupStateManager,
|
||||
private val backendManager: BackendManager,
|
||||
private val loader: Loader,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
@Suppress("Deprecation")
|
||||
private val legacyStoragePlugin: LegacyStoragePlugin,
|
||||
private val crypto: Crypto,
|
||||
|
@ -64,7 +54,7 @@ internal class ApkRestore(
|
|||
) {
|
||||
|
||||
private val pm = context.packageManager
|
||||
private val backend get() = backendManager.backend
|
||||
private val storagePlugin get() = pluginManager.appPlugin
|
||||
|
||||
private val mInstallResult = MutableStateFlow(InstallResult())
|
||||
val installResult = mInstallResult.asStateFlow()
|
||||
|
@ -75,7 +65,7 @@ internal class ApkRestore(
|
|||
val packages = backup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
|
||||
// We need to exclude the DocumentsProvider used to retrieve backup data.
|
||||
// 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
|
||||
if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null
|
||||
// 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)
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
} catch (e: Exception) {
|
||||
if (e::class.simpleName == "MockKException") throw e
|
||||
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
|
||||
mInstallResult.update { it.fail(packageName) }
|
||||
}
|
||||
|
@ -164,12 +153,7 @@ internal class ApkRestore(
|
|||
}
|
||||
|
||||
@Suppress("ThrowsCount")
|
||||
@Throws(
|
||||
GeneralSecurityException::class,
|
||||
UnsupportedVersionException::class,
|
||||
IOException::class,
|
||||
SecurityException::class,
|
||||
)
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
private suspend fun restore(
|
||||
backup: RestorableBackup,
|
||||
packageName: String,
|
||||
|
@ -183,10 +167,10 @@ internal class ApkRestore(
|
|||
}
|
||||
|
||||
// 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
|
||||
if (backup.version < 2 && metadata.sha256 != sha256) throw SecurityException(
|
||||
// check APK's SHA-256 hash
|
||||
if (metadata.sha256 != sha256) throw SecurityException(
|
||||
"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.
|
||||
* @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
|
||||
val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name
|
||||
val (file, sha256) = cacheApk(backup, packageName, apkSplit.chunkIds, suffix)
|
||||
// check APK split's SHA-256 hash for backup versions before 2
|
||||
if (backup.version < 2 && apkSplit.sha256 != sha256) throw SecurityException(
|
||||
val salt = backup.salt
|
||||
val (file, sha256) = cacheApk(backup.version, backup.token, salt, packageName, suffix)
|
||||
// check APK split's SHA-256 hash
|
||||
if (apkSplit.sha256 != sha256) throw SecurityException(
|
||||
"$packageName:${apkSplit.name} has sha256 '$sha256'," +
|
||||
" 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.
|
||||
*
|
||||
* @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(
|
||||
backup: RestorableBackup,
|
||||
version: Byte,
|
||||
token: Long,
|
||||
salt: String,
|
||||
packageName: String,
|
||||
chunkIds: List<String>?,
|
||||
suffix: String = "",
|
||||
): Pair<File, String> {
|
||||
// create a cache file to write the APK into
|
||||
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
|
||||
// copy APK to cache file and calculate SHA-256 hash while we are at it
|
||||
val inputStream = when (backup.version) {
|
||||
0.toByte() -> {
|
||||
legacyStoragePlugin.getApkInputStream(backup.token, packageName, suffix)
|
||||
}
|
||||
1.toByte() -> {
|
||||
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 inputStream = if (version == 0.toByte()) {
|
||||
legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
|
||||
} else {
|
||||
val name = crypto.getNameForApk(salt, packageName, suffix)
|
||||
storagePlugin.getInputStream(token, name)
|
||||
}
|
||||
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
|
||||
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 { ApkSplitCompatibilityChecker(get()) }
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@ internal class InstallProgressAdapter(
|
|||
if (item.icon == null) iconJob = scope.launch {
|
||||
iconLoader(item, appIcon::setImageDrawable)
|
||||
} 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
|
||||
when (item.state) {
|
||||
IN_PROGRESS -> {
|
||||
|
|
|
@ -26,11 +26,11 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.stevesoltys.seedvault.R
|
||||
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 {
|
||||
|
||||
private val viewModel: RestoreViewModel by activityViewModel()
|
||||
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||
|
||||
private val layoutManager = LinearLayoutManager(context)
|
||||
private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this)
|
||||
|
|
|
@ -11,7 +11,6 @@ import android.view.LayoutInflater
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
|
@ -42,11 +41,12 @@ class AboutDialogFragment : Fragment() {
|
|||
contributorsView.movementMethod = linkMovementMethod
|
||||
orgsView.movementMethod = linkMovementMethod
|
||||
|
||||
v.requireViewById<Toolbar>(R.id.toolbar).setNavigationOnClickListener {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
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.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||
import com.stevesoltys.seedvault.R
|
||||
|
@ -29,6 +30,8 @@ import com.stevesoltys.seedvault.ui.notification.getAppName
|
|||
import com.stevesoltys.seedvault.ui.systemData
|
||||
import java.util.Locale
|
||||
|
||||
private const val TAG = "AppListRetriever"
|
||||
|
||||
sealed class AppListItem
|
||||
|
||||
data class AppStatus(
|
||||
|
@ -59,6 +62,7 @@ internal class AppListRetriever(
|
|||
val appListSections = linkedMapOf(
|
||||
AppSectionTitle(R.string.backup_section_system) to getSpecialApps(),
|
||||
AppSectionTitle(R.string.backup_section_user) to getApps(),
|
||||
AppSectionTitle(R.string.backup_section_not_allowed) to getNotAllowedApps()
|
||||
).filter { it.value.isNotEmpty() }
|
||||
|
||||
return appListSections.flatMap { (sectionTitle, appList) ->
|
||||
|
@ -77,7 +81,8 @@ internal class AppListRetriever(
|
|||
AppStatus(
|
||||
packageName = 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),
|
||||
time = metadata?.time ?: 0,
|
||||
size = metadata?.size,
|
||||
|
@ -94,11 +99,14 @@ internal class AppListRetriever(
|
|||
val metadata = metadataManager.getPackageMetadata(it.packageName)
|
||||
val time = metadata?.time ?: 0
|
||||
val status = metadata?.state.toAppBackupState()
|
||||
if (status == NOT_YET_BACKED_UP) {
|
||||
Log.w(TAG, "No metadata available for: ${it.packageName}")
|
||||
}
|
||||
AppStatus(
|
||||
packageName = it.packageName,
|
||||
enabled = settingsManager.isBackupEnabled(it.packageName),
|
||||
icon = getIconFromPackageManager(it.packageName),
|
||||
name = metadata?.name?.toString() ?: getAppName(context, it.packageName).toString(),
|
||||
name = getAppName(context, it.packageName).toString(),
|
||||
time = time,
|
||||
size = metadata?.size,
|
||||
status = status,
|
||||
|
@ -107,18 +115,13 @@ internal class AppListRetriever(
|
|||
val locale = Locale.getDefault()
|
||||
return (userApps + packageService.launchableSystemApps.mapNotNull {
|
||||
val packageName = it.activityInfo.packageName
|
||||
if (packageName in userPackages || packageName == context.packageName) {
|
||||
// don't re-add user packages again,
|
||||
// also on some ROMs we are a launchableSystemApp, so we need to exclude ourselves
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (packageName in userPackages) return@mapNotNull null
|
||||
val metadata = metadataManager.getPackageMetadata(packageName)
|
||||
AppStatus(
|
||||
packageName = packageName,
|
||||
enabled = settingsManager.isBackupEnabled(packageName),
|
||||
icon = getIconFromPackageManager(packageName),
|
||||
name = metadata?.name?.toString()
|
||||
?: it.loadLabel(context.packageManager).toString(),
|
||||
name = it.loadLabel(context.packageManager).toString(),
|
||||
time = metadata?.time ?: 0,
|
||||
size = metadata?.size,
|
||||
status = metadata?.state.toAppBackupState(),
|
||||
|
@ -126,6 +129,21 @@ internal class AppListRetriever(
|
|||
}).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 {
|
||||
pm.getApplicationIcon(packageName)
|
||||
} 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