1
0
Fork 0

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
scripts
workflows
.idea/codeStyles
Android.bpCHANGELOG.mdREADME.md
app
build.gradle.kts
build/generated/source/proto/debug/kotlin/com/stevesoltys/seedvault/proto
libs
src
androidTest/java/com/stevesoltys/seedvault
main

View file

@ -1,66 +1,13 @@
container: task:
image: ghcr.io/cirruslabs/android-sdk:34 name: Build with AOSP
kvm: true only_if: $CIRRUS_PR_LABELS =~ ".*aosp-build.*"
cpu: 8 timeout_in: 70m
memory: 16G container:
image: ubuntu:23.04
instrumentation_tests_task: cpu: 8
name: "Cirrus CI Instrumentation Tests" memory: 32G
start_avd_background_script: build_script:
sdkmanager --install "system-images;android-34;default;x86_64" "emulator"; - ./.github/scripts/build_aosp.sh aosp_arm64 ap1a userdebug android-14.0.0_r29
echo no | avdmanager create avd -n seedvault -k "system-images;android-34;default;x86_64";
$ANDROID_HOME/emulator/emulator
-avd seedvault
-no-audio
-no-boot-anim
-gpu swiftshader_indirect
-no-snapshot
-no-window
-writable-system;
provision_avd_background_script:
wget https://github.com/seedvault-app/seedvault-test-data/releases/download/3/backup.tar.gz;
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
adb root;
sleep 5;
adb remount;
adb reboot;
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
adb root;
sleep 5;
adb remount;
sleep 5;
assemble_script:
./gradlew :app:assembleRelease :contacts:assembleRelease assembleAndroidTest
install_app_script:
timeout 180s bash -c 'while [[ -z $(adb shell mount | grep "/system " | grep "(rw,") ]]; do sleep 1; done;';
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
adb shell mkdir -p /sdcard/seedvault_baseline;
adb push backup.tar.gz /sdcard/seedvault_baseline/backup.tar.gz;
adb shell tar xzf /sdcard/seedvault_baseline/backup.tar.gz --directory=/sdcard/seedvault_baseline;
adb shell mkdir -p /system/priv-app/Seedvault;
adb push app/build/outputs/apk/release/app-release.apk /system/priv-app/Seedvault/Seedvault.apk;
adb push permissions_com.stevesoltys.seedvault.xml /system/etc/permissions/privapp-permissions-seedvault.xml;
adb push allowlist_com.stevesoltys.seedvault.xml /system/etc/sysconfig/allowlist-seedvault.xml;
adb shell mkdir -p /system/priv-app/ContactsBackup;
adb push contactsbackup/build/outputs/apk/release/contactsbackup-release.apk /system/priv-app/ContactsBackup/contactsbackup.apk;
adb push contactsbackup/default-permissions_org.calyxos.backup.contacts.xml /system/etc/default-permissions/default-permissions_org.calyxos.backup.contacts.xml;
adb shell bmgr enable true;
adb reboot;
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
adb shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport;
adb reboot;
adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done;';
run_large_tests_script: ./gradlew -Pandroid.testInstrumentationRunnerArguments.size=large :app:connectedAndroidTest
run_other_tests_script: ./gradlew -Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.LargeTest connectedAndroidTest
always: always:
pull_screenshots_script: seedvault_artifacts:
adb pull /sdcard/seedvault_test_results path: Seedvault.apk
screenshots_artifacts:
path: "seedvault_test_results/**/*.mp4"
logcat_artifacts:
path: "seedvault_test_results/**/*.log"

View file

@ -10,8 +10,10 @@ echo "Installing Seedvault app..."
./gradlew --stacktrace :app:installDebugAndroidTest ./gradlew --stacktrace :app:installDebugAndroidTest
sleep 60 sleep 60
D2D_BACKUP_TEST=$1
large_test_exit_code=0 large_test_exit_code=0
./gradlew --stacktrace -Pinstrumented_test_size=large :app:connectedAndroidTest || large_test_exit_code=$? ./gradlew --stacktrace -Pinstrumented_test_size=large -Pd2d_backup_test="$D2D_BACKUP_TEST" :app:connectedAndroidTest || large_test_exit_code=$?
adb pull /sdcard/seedvault_test_results adb pull /sdcard/seedvault_test_results

View file

@ -20,6 +20,7 @@ jobs:
matrix: matrix:
android_target: [ 34 ] android_target: [ 34 ]
emulator_type: [ aosp_atd ] emulator_type: [ aosp_atd ]
d2d_backup_test: [ true, false ]
steps: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v3 uses: actions/checkout@v3
@ -52,7 +53,7 @@ jobs:
disable-animations: true disable-animations: true
script: | script: |
./app/development/scripts/provision_emulator.sh "test" "system-images;android-${{ matrix.android_target }};${{ matrix.emulator_type }};x86_64" ./app/development/scripts/provision_emulator.sh "test" "system-images;android-${{ matrix.android_target }};${{ matrix.emulator_type }};x86_64"
./.github/scripts/run_tests.sh ./.github/scripts/run_tests.sh ${{ matrix.d2d_backup_test }}
- name: Upload test results - name: Upload test results
if: always() if: always()

View file

@ -1,7 +1,12 @@
<component name="ProjectCodeStyleConfiguration"> <component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173"> <code_scheme name="Project" version="173">
<JetCodeStyleSettings> <JetCodeStyleSettings>
<option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" /> <option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings> </JetCodeStyleSettings>
<codeStyleSettings language="XML"> <codeStyleSettings language="XML">

View file

@ -8,23 +8,12 @@ android_app {
srcs: [ srcs: [
"app/src/main/java/**/*.kt", "app/src/main/java/**/*.kt",
"app/src/main/java/**/*.java", "app/src/main/java/**/*.java",
"app/src/main/proto/*.proto",
// as of Android 15, there is no way to pass --kotlin_out to aprotoc compiler
"app/build/generated/source/proto/debug/kotlin/com/stevesoltys/seedvault/proto/*.kt",
], ],
resource_dirs: [ resource_dirs: [
"app/src/main/res", "app/src/main/res",
], ],
asset_dirs: [
"app/src/main/assets"
],
proto: {
type: "lite",
local_include_dirs: ["app/src/main/proto"],
},
static_libs: [ static_libs: [
"kotlin-stdlib-jdk8", "kotlin-stdlib-jdk8",
"libprotobuf-java-lite",
"androidx.core_core-ktx", "androidx.core_core-ktx",
"androidx.fragment_fragment-ktx", "androidx.fragment_fragment-ktx",
"androidx.activity_activity-ktx", "androidx.activity_activity-ktx",
@ -37,23 +26,18 @@ android_app {
"com.google.android.material_material", "com.google.android.material_material",
"kotlinx-coroutines-android", "kotlinx-coroutines-android",
"kotlinx-coroutines-core", "kotlinx-coroutines-core",
"seedvault-lib-kotlin-logging-jvm", // storage backup lib
// app backup related libs
"seedvault-lib-protobuf-kotlin-lite",
"seedvault-logback-android",
"seedvault-lib-chunker",
"seedvault-lib-zstd-jni",
"okio-lib",
// our own gradle module libs
"seedvault-lib-core",
"seedvault-lib-storage", "seedvault-lib-storage",
// koin // koin
"seedvault-lib-koin-core-jvm", // did not manage to add this as transitive dependency "seedvault-lib-koin-core-jvm", // did not manage to add this as transitive dependency
"seedvault-lib-koin-android", "seedvault-lib-koin-android",
// bip39 // bip39
"seedvault-lib-kotlin-bip39", "seedvault-lib-kotlin-bip39",
// WebDAV
"seedvault-lib-dav4jvm",
"seedvault-lib-okhttp",
"seedvault-lib-okio",
], ],
use_embedded_native_libs: true,
manifest: "app/src/main/AndroidManifest.xml", manifest: "app/src/main/AndroidManifest.xml",
platform_apis: true, platform_apis: true,

View file

@ -1,15 +1,3 @@
## [15-5.0] - 2024-10-15
* First Android 15 release
* New backup format using compression and deduplication
* Can still restore old backups, but old Seedvault can't restore backups from this version
* Faster and more reliable backups making snapshots that can individually be restored
* Auto-cleaning of old backups
* All backups now mimic device-to-device (allowing backup for all apps)
* All backups now use a high per-app app quota
* App backup (for APKs) moved to expert settings
* Show more information for backups available to restore
* Fix "Waiting to back up..." showing for apps
## [14-4.1] - 2024-08-23 ## [14-4.1] - 2024-08-23
* It is now possible to restore after setting up a profile * It is now possible to restore after setting up a profile
* It is now possible to select what to restore (e.g. apps, files...) * It is now possible to select what to restore (e.g. apps, files...)

View file

@ -2,20 +2,14 @@
[![Build](https://github.com/seedvault-app/seedvault/actions/workflows/build.yml/badge.svg)](https://github.com/seedvault-app/seedvault/actions/workflows/build.yml) [![Build](https://github.com/seedvault-app/seedvault/actions/workflows/build.yml/badge.svg)](https://github.com/seedvault-app/seedvault/actions/workflows/build.yml)
A backup application for the [Android Open Source Project](https://source.android.com/). A backup application for the [Android Open Source Project](https://source.android.com/).
Needs to be [integrated](https://github.com/seedvault-app/seedvault/wiki/ROM-Integration)
in your Android ROM and **can not** be installed as a regular app.
If you are having an issue/question, If you are having an issue/question, please look at our [FAQ](https://github.com/seedvault-app/seedvault/wiki/FAQ).
please look at our [FAQ](https://github.com/seedvault-app/seedvault/wiki/FAQ)
or [ask a new question](https://github.com/seedvault-app/seedvault/discussions).
## Components ## Components
* [Local Contacts Backup](contactsbackup) - an app that backs up local on-device contacts * [Local Contacts Backup](contactsbackup) - an app that backs up local on-device contacts
* [File backup library](storage) - a library handling efficient backup of files * [Storage library](storage) - a library handling efficient backup of files
([documentation](storage/doc/design.md))
* [Seedvault app](app) - the main app where all functionality comes together * [Seedvault app](app) - the main app where all functionality comes together
([documentation](doc/README.md))
## Features ## Features
- Backup application data to a flash drive. - Backup application data to a flash drive.
@ -25,27 +19,24 @@ or [ask a new question](https://github.com/seedvault-app/seedvault/discussions).
## Requirements ## Requirements
SeedVault is developed along with AOSP releases. SeedVault is developed along with AOSP releases
We update it every time Google releases a new Android version, We update it every time Google releases a new Android version, make any changes required for basic functionality, and any improvements possible through API changes in the OS.
make any changes required for basic functionality,
and any improvements possible through API changes in the OS.
This means that for ROMs using SeedVault it's recommended This means that for ROMs using SeedVault it's recommended to use the same branch as your android version
to use the same branch as your android version
- This current branch `android15` is meant for usage with Android 15 - This current branch `android14` is meant for usage with Android 14
- This is indicated by the version name starting with `15`, - This is indicated by the version name starting with `14`, and the version code starting with `34` - the Android 14 API version
and the version code starting with `35` - the Android 15 API version
For older versions of Android, For older versions of Android, check out [the branches](https://github.com/seedvault-app/seedvault/branches).
check out [the branches](https://github.com/seedvault-app/seedvault/branches).
Trying to use an older branch on a newer version may lead to issues Trying to use an older branch on a newer version may lead to issues and is not something we can support.
and is not something we can support.
## Getting Started
- Check out [the wiki](https://github.com/seedvault-app/seedvault/wiki) for information on building the application with
AOSP.
## What makes this different? ## What makes this different?
This application is compiled with the operating system and does not require a rooted device for use. This application is compiled with the operating system and does not require a rooted device for use.
It uses the same internal APIs as `adb backup` which is deprecated and thus needs a replacement. It uses the same internal APIs as `adb backup` which is deprecated and thus needs a replacement.
@ -69,11 +60,9 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need
## Contributing ## Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault. Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault.
See [DEVELOPMENT.md](app/development/DEVELOPMENT.md) for information See [DEVELOPMENT.md](app/development/DEVELOPMENT.md) for information on developing Seedvault locally.
on developing Seedvault locally.
This project aims to adhere to the This project aims to adhere to the [official Kotlin coding style](https://developer.android.com/kotlin/style-guide).
[official Kotlin coding style](https://developer.android.com/kotlin/style-guide).
## Third-party tools ## Third-party tools
@ -89,8 +78,7 @@ allows you to decrypt and inspect your backups from newer versions of Seedvault
It is currently work-in-progress. It is currently work-in-progress.
## License ## License
This application is available as open source under the terms This application is available as open source under the terms of the [Apache-2.0 License](https://opensource.org/licenses/Apache-2.0).
of the [Apache-2.0 License](https://opensource.org/licenses/Apache-2.0).
## Funding ## Funding
@ -106,12 +94,3 @@ a fund established by [NLnet](https://nlnet.nl)
with financial support from the European Commission's Next Generation Internet programme, with financial support from the European Commission's Next Generation Internet programme,
under the aegis of DG Communications Networks, Content and Technology under the aegis of DG Communications Networks, Content and Technology
under grant agreement No 825310. under grant agreement No 825310.
### NGI0 Entrust Fund
This project was funded through the
[NGI0 Entrust Fund](https://nlnet.nl/project/SeedVault-Integrity/),
a fund established by [NLnet](https://nlnet.nl)
with financial support from the European Commission's Next Generation Internet programme,
under the aegis of DG Communications Networks, Content and Technology
under grant agreement No 101069594.

View file

@ -3,14 +3,12 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// //
import com.google.protobuf.gradle.id
import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.google.protobuf)
} }
val gitDescribe = { val gitDescribe = {
@ -32,6 +30,16 @@ android {
versionNameSuffix = "-${gitDescribe()}" versionNameSuffix = "-${gitDescribe()}"
testInstrumentationRunner = "com.stevesoltys.seedvault.KoinInstrumentationTestRunner" testInstrumentationRunner = "com.stevesoltys.seedvault.KoinInstrumentationTestRunner"
testInstrumentationRunnerArguments["disableAnalytics"] = "true" testInstrumentationRunnerArguments["disableAnalytics"] = "true"
if (project.hasProperty("instrumented_test_size")) {
val testSize = project.property("instrumented_test_size").toString()
println("Instrumented test size: $testSize")
testInstrumentationRunnerArguments["size"] = testSize
}
val d2dBackupTest = project.findProperty("d2d_backup_test")?.toString() ?: "true"
testInstrumentationRunnerArguments["d2d_backup_test"] = d2dBackupTest
} }
signingConfigs { signingConfigs {
@ -85,30 +93,6 @@ android {
} }
} }
protobuf {
protoc {
artifact = if ("aarch64" == System.getProperty("os.arch")) {
// mac m1
"com.google.protobuf:protoc:${libs.versions.protobuf.get()}:osx-x86_64"
} else {
// other
"com.google.protobuf:protoc:${libs.versions.protobuf.get()}"
}
}
generateProtoTasks {
all().forEach { task ->
task.plugins {
id("java") {
option("lite")
}
id("kotlin") {
option("lite")
}
}
}
}
}
lint { lint {
abortOnError = true abortOnError = true
@ -122,7 +106,19 @@ android {
} }
dependencies { dependencies {
val aospLibs: FileTree by rootProject.extra
val aospLibs = fileTree("$projectDir/libs") {
// For more information about this module:
// https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507
// framework_intermediates/classes-header.jar works for gradle build as well,
// but not unit tests, so we use the actual classes (without updatable modules).
//
// out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
include("android.jar")
// out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art_intermediates/classes.jar
include("libcore.jar")
}
compileOnly(aospLibs) compileOnly(aospLibs)
/** /**
@ -148,15 +144,11 @@ dependencies {
implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.work.runtime.ktx)
implementation(libs.google.material) implementation(libs.google.material)
implementation(libs.google.protobuf.javalite)
implementation(libs.google.tink.android) implementation(libs.google.tink.android)
implementation(libs.kotlin.logging)
implementation(libs.squareup.okio)
/** /**
* Storage Dependencies * Storage Dependencies
*/ */
implementation(project(":core"))
implementation(project(":storage:lib")) implementation(project(":storage:lib"))
/** /**
@ -170,13 +162,9 @@ dependencies {
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.jar")) implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.jar"))
implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.aar")) implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.aar"))
implementation( implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
fileTree("${rootProject.rootDir}/libs").include("protobuf-kotlin-lite-3.21.12.jar")
) implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
implementation(fileTree("${rootProject.rootDir}/libs").include("seedvault-chunker-0.1.jar"))
implementation(fileTree("${rootProject.rootDir}/libs").include("zstd-jni-1.5.6-5.aar"))
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.8.jar"))
implementation(fileTree("${rootProject.rootDir}/libs").include("logback-android-3.0.0.aar"))
/** /**
* Test Dependencies (do not concern the AOSP build) * Test Dependencies (do not concern the AOSP build)
@ -186,7 +174,6 @@ dependencies {
// anything less than 'implementation' fails tests run with gradlew // anything less than 'implementation' fails tests run with gradlew
testImplementation(aospLibs) testImplementation(aospLibs)
testImplementation("androidx.test.ext:junit:1.1.5") testImplementation("androidx.test.ext:junit:1.1.5")
testImplementation("org.slf4j:slf4j-simple:2.0.3")
testImplementation("org.robolectric:robolectric:4.12.2") testImplementation("org.robolectric:robolectric:4.12.2")
testImplementation("org.hamcrest:hamcrest:2.2") testImplementation("org.hamcrest:hamcrest:2.2")
testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}") testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
@ -197,12 +184,10 @@ dependencies {
) )
testImplementation("app.cash.turbine:turbine:1.0.0") testImplementation("app.cash.turbine:turbine:1.0.0")
testImplementation("org.bitcoinj:bitcoinj-core:0.16.2") testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
testImplementation("com.github.luben:zstd-jni:1.5.6-5")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
androidTestImplementation(aospLibs) androidTestImplementation(aospLibs)
androidTestImplementation(kotlin("test"))
androidTestImplementation("androidx.test:runner:1.4.0") androidTestImplementation("androidx.test:runner:1.4.0")
androidTestImplementation("androidx.test:rules:1.4.0") androidTestImplementation("androidx.test:rules:1.4.0")
androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.ext:junit:1.1.3")
@ -212,7 +197,7 @@ dependencies {
gradle.projectsEvaluated { gradle.projectsEvaluated {
tasks.withType(JavaCompile::class) { tasks.withType(JavaCompile::class) {
options.compilerArgs.add("-Xbootclasspath/p:libs/aosp/android.jar:libs/aosp/libcore.jar") options.compilerArgs.add("-Xbootclasspath/p:app/libs/android.jar:app/libs/libcore.jar")
} }
} }

View file

@ -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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; 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()

View file

@ -32,16 +32,16 @@ class KoinInstrumentationTestApp : App() {
val testModule = module { val testModule = module {
val context = this@KoinInstrumentationTestApp val context = this@KoinInstrumentationTestApp
single { spyk(PackageService(context, get(), get())) } single { spyk(PackageService(context, get(), get(), get())) }
single { spyk(SettingsManager(context)) } single { spyk(SettingsManager(context)) }
single { spyk(BackupNotificationManager(context)) } single { spyk(BackupNotificationManager(context)) }
single { spyk(FullBackup(get(), get(), get(), get())) } single { spyk(FullBackup(get(), get(), get(), get(), get())) }
single { spyk(KVBackup(get(), get(), get())) } single { spyk(KVBackup(get(), get(), get(), get(), get(), get())) }
single { spyk(InputFactory()) } single { spyk(InputFactory()) }
single { spyk(FullRestore(get(), get(), get(), get(), get(), get())) } single { spyk(FullRestore(get(), get(), get(), get(), get())) }
single { spyk(KVRestore(get(), get(), get(), get(), get(), get(), get())) } single { spyk(KVRestore(get(), get(), get(), get(), get(), get())) }
single { spyk(OutputFactory()) } single { spyk(OutputFactory()) }
viewModel { viewModel {
@ -53,11 +53,10 @@ class KoinInstrumentationTestApp : App() {
keyManager = get(), keyManager = get(),
backupManager = get(), backupManager = get(),
restoreCoordinator = get(), restoreCoordinator = get(),
appBackupManager = get(),
apkRestore = get(), apkRestore = get(),
iconManager = get(), iconManager = get(),
storageBackup = get(), storageBackup = get(),
backendManager = get(), pluginManager = get(),
fileSelectionManager = get(), fileSelectionManager = get(),
) )
) )

View file

@ -5,22 +5,26 @@
package com.stevesoltys.seedvault package com.stevesoltys.seedvault
import android.net.Uri
import androidx.test.core.content.pm.PackageInfoBuilder import androidx.test.core.content.pm.PackageInfoBuilder
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.backend.saf.DocumentsProviderLegacyPlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.backend.saf.DocumentsStorage import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderLegacyPlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.plugins.saf.deleteContents
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import org.calyxos.seedvault.core.backends.saf.SafBackend
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
@ -38,10 +42,11 @@ class PluginTest : KoinComponent {
private val mockedSettingsManager: SettingsManager = mockk() private val mockedSettingsManager: SettingsManager = mockk()
private val storage = DocumentsStorage( private val storage = DocumentsStorage(
appContext = context, appContext = context,
safStorage = settingsManager.getSafProperties() ?: error("No SAF storage"), settingsManager = mockedSettingsManager,
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
) )
private val backend = SafBackend(context, storage.safStorage) private val storagePlugin: StoragePlugin<Uri> = DocumentsProviderStoragePlugin(context, storage)
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) { private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
@ -54,30 +59,30 @@ class PluginTest : KoinComponent {
@Before @Before
fun setup() = runBlocking { fun setup() = runBlocking {
every { every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage()
mockedSettingsManager.getSafProperties() storage.rootBackupDir?.deleteContents(context)
} returns settingsManager.getSafProperties() ?: error("Select a storage location in the app first!")
backend.removeAll()
} }
@After @After
fun tearDown() = runBlocking { fun tearDown() = runBlocking {
backend.removeAll() storage.rootBackupDir?.deleteContents(context)
Unit
} }
@Test @Test
fun testProviderPackageName() { fun testProviderPackageName() {
assertNotNull(backend.providerPackageName) assertNotNull(storagePlugin.providerPackageName)
} }
@Test @Test
fun testTest() = runBlocking(Dispatchers.IO) { fun testTest() = runBlocking(Dispatchers.IO) {
assertTrue(backend.test()) assertTrue(storagePlugin.test())
} }
@Test @Test
fun testGetFreeSpace() = runBlocking(Dispatchers.IO) { fun testGetFreeSpace() = runBlocking(Dispatchers.IO) {
val freeBytes = backend.getFreeSpace() ?: error("no free space retrieved") val freeBytes = storagePlugin.getFreeSpace() ?: error("no free space retrieved")
assertTrue(freeBytes > 0) assertTrue(freeBytes > 0)
} }
@ -91,66 +96,80 @@ class PluginTest : KoinComponent {
@Test @Test
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) { fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
// no backups available initially // no backups available initially
assertEquals(0, backend.getAvailableBackupFileHandles().toList().size) assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size)
// prepare returned tokens requested when initializing device // prepare returned tokens requested when initializing device
every { mockedSettingsManager.token } returnsMany listOf(token, token + 1, token + 1) every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
// start new restore set and initialize device afterwards
storagePlugin.startNewRestoreSet(token)
storagePlugin.initializeDevice()
// write metadata (needed for backup to be recognized) // write metadata (needed for backup to be recognized)
backend.save(LegacyAppBackupFile.Metadata(token)) storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA)
.writeAndClose(getRandomByteArray()) .writeAndClose(getRandomByteArray())
// one backup available now // one backup available now
assertEquals(1, backend.getAvailableBackupFileHandles().toList().size) assertEquals(1, storagePlugin.getAvailableBackups()?.toList()?.size)
// initializing again (with another restore set) does add a restore set // initializing again (with another restore set) does add a restore set
backend.save(LegacyAppBackupFile.Metadata(token + 1)) storagePlugin.startNewRestoreSet(token + 1)
storagePlugin.initializeDevice()
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
.writeAndClose(getRandomByteArray()) .writeAndClose(getRandomByteArray())
assertEquals(2, backend.getAvailableBackupFileHandles().toList().size) assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
// initializing again (without new restore set) doesn't change number of restore sets // initializing again (without new restore set) doesn't change number of restore sets
backend.save(LegacyAppBackupFile.Metadata(token + 1)) storagePlugin.initializeDevice()
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
.writeAndClose(getRandomByteArray()) .writeAndClose(getRandomByteArray())
assertEquals(2, backend.getAvailableBackupFileHandles().toList().size) assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
// ensure that the new backup dir exist
assertTrue(storage.currentSetDir!!.exists())
} }
@Test @Test
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) { fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
every { mockedSettingsManager.token } returns token every { mockedSettingsManager.getToken() } returns token
storagePlugin.startNewRestoreSet(token)
storagePlugin.initializeDevice()
// write metadata // write metadata
val metadata = getRandomByteArray() val metadata = getRandomByteArray()
backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata) storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA).writeAndClose(metadata)
// get available backups, expect only one with our token and no error // get available backups, expect only one with our token and no error
var availableBackups = backend.getAvailableBackupFileHandles().toList() var availableBackups = storagePlugin.getAvailableBackups()?.toList()
check(availableBackups != null)
assertEquals(1, availableBackups.size) assertEquals(1, availableBackups.size)
var backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata assertEquals(token, availableBackups[0].token)
assertEquals(token, backupHandle.token)
// read metadata matches what was written earlier // read metadata matches what was written earlier
assertReadEquals(metadata, backend.load(backupHandle)) assertReadEquals(metadata, availableBackups[0].inputStreamRetriever())
// initializing again (without changing storage) keeps restore set with same token // initializing again (without changing storage) keeps restore set with same token
backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata) storagePlugin.initializeDevice()
availableBackups = backend.getAvailableBackupFileHandles().toList() storagePlugin.getOutputStream(token, FILE_BACKUP_METADATA).writeAndClose(metadata)
availableBackups = storagePlugin.getAvailableBackups()?.toList()
check(availableBackups != null)
assertEquals(1, availableBackups.size) assertEquals(1, availableBackups.size)
backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata assertEquals(token, availableBackups[0].token)
assertEquals(token, backupHandle.token)
// metadata hasn't changed // metadata hasn't changed
assertReadEquals(metadata, backend.load(backupHandle)) assertReadEquals(metadata, availableBackups[0].inputStreamRetriever())
} }
@Test @Test
@Suppress("Deprecation")
fun v0testApkWriteRead() = runBlocking { fun v0testApkWriteRead() = runBlocking {
// initialize storage with given token // initialize storage with given token
initStorage(token) initStorage(token)
// write random bytes as APK // write random bytes as APK
val apk1 = getRandomByteArray(1337 * 1024) val apk1 = getRandomByteArray(1337 * 1024)
backend.save(LegacyAppBackupFile.Blob(token, "${packageInfo.packageName}.apk")) storagePlugin.getOutputStream(token, "${packageInfo.packageName}.apk").writeAndClose(apk1)
.writeAndClose(apk1)
// assert that read APK bytes match what was written // assert that read APK bytes match what was written
assertReadEquals( assertReadEquals(
@ -162,7 +181,7 @@ class PluginTest : KoinComponent {
val suffix2 = getRandomBase64(23) val suffix2 = getRandomBase64(23)
val apk2 = getRandomByteArray(23 * 1024 * 1024) val apk2 = getRandomByteArray(23 * 1024 * 1024)
backend.save(LegacyAppBackupFile.Blob(token, "${packageInfo2.packageName}$suffix2.apk")) storagePlugin.getOutputStream(token, "${packageInfo2.packageName}$suffix2.apk")
.writeAndClose(apk2) .writeAndClose(apk2)
// assert that read APK bytes match what was written // assert that read APK bytes match what was written
@ -180,27 +199,42 @@ class PluginTest : KoinComponent {
val name1 = getRandomBase64() val name1 = getRandomBase64()
val name2 = getRandomBase64() val name2 = getRandomBase64()
// no data available initially
assertFalse(storagePlugin.hasData(token, name1))
assertFalse(storagePlugin.hasData(token, name2))
// write full backup data // write full backup data
val data = getRandomByteArray(5 * 1024 * 1024) val data = getRandomByteArray(5 * 1024 * 1024)
backend.save(LegacyAppBackupFile.Blob(token, name1)).writeAndClose(data) storagePlugin.getOutputStream(token, name1).writeAndClose(data)
// data is available now, but only this token
assertTrue(storagePlugin.hasData(token, name1))
assertFalse(storagePlugin.hasData(token + 1, name1))
// restore data matches backed up data // restore data matches backed up data
assertReadEquals(data, backend.load(LegacyAppBackupFile.Blob(token, name1))) assertReadEquals(data, storagePlugin.getInputStream(token, name1))
// write and check data for second package // write and check data for second package
val data2 = getRandomByteArray(5 * 1024 * 1024) val data2 = getRandomByteArray(5 * 1024 * 1024)
backend.save(LegacyAppBackupFile.Blob(token, name2)).writeAndClose(data2) storagePlugin.getOutputStream(token, name2).writeAndClose(data2)
assertReadEquals(data2, backend.load(LegacyAppBackupFile.Blob(token, name2))) assertTrue(storagePlugin.hasData(token, name2))
assertReadEquals(data2, storagePlugin.getInputStream(token, name2))
// remove data of first package again and ensure that no more data is found // remove data of first package again and ensure that no more data is found
backend.remove(LegacyAppBackupFile.Blob(token, name1)) storagePlugin.removeData(token, name1)
assertFalse(storagePlugin.hasData(token, name1))
// second package is still there
assertTrue(storagePlugin.hasData(token, name2))
// ensure that it gets deleted as well // ensure that it gets deleted as well
backend.remove(LegacyAppBackupFile.Blob(token, name2)) storagePlugin.removeData(token, name2)
assertFalse(storagePlugin.hasData(token, name2))
} }
private fun initStorage(token: Long) = runBlocking { private fun initStorage(token: Long) = runBlocking {
every { mockedSettingsManager.token } returns token every { mockedSettingsManager.getToken() } returns token
storagePlugin.initializeDevice()
} }
} }

View file

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

View file

@ -9,6 +9,7 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept
import com.stevesoltys.seedvault.e2e.io.InputStreamIntercept
import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen import com.stevesoltys.seedvault.e2e.screen.impl.BackupScreen
import com.stevesoltys.seedvault.transport.backup.FullBackup import com.stevesoltys.seedvault.transport.backup.FullBackup
import com.stevesoltys.seedvault.transport.backup.InputFactory import com.stevesoltys.seedvault.transport.backup.InputFactory
@ -20,12 +21,9 @@ import io.mockk.every
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.calyxos.seedvault.core.toHexString
import org.koin.core.component.get import org.koin.core.component.get
import java.security.DigestInputStream import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.fail
internal interface LargeBackupTestBase : LargeTestBase { internal interface LargeBackupTestBase : LargeTestBase {
@ -76,6 +74,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
full = mutableMapOf(), full = mutableMapOf(),
kv = mutableMapOf(), kv = mutableMapOf(),
userApps = packageService.userApps, userApps = packageService.userApps,
userNotAllowedApps = packageService.userNotAllowedApps
) )
val completed = spyOnBackup(backupResult) val completed = spyOnBackup(backupResult)
@ -112,7 +111,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
var data = mutableMapOf<String, ByteArray>() var data = mutableMapOf<String, ByteArray>()
coEvery { coEvery {
spyKVBackup.performBackup(any(), any(), any()) spyKVBackup.performBackup(any(), any(), any(), any(), any())
} answers { } answers {
packageName = firstArg<PackageInfo>().packageName packageName = firstArg<PackageInfo>().packageName
callOriginal() callOriginal()
@ -155,11 +154,10 @@ internal interface LargeBackupTestBase : LargeTestBase {
private fun spyOnFullBackupData(backupResult: SeedvaultLargeTestResult) { private fun spyOnFullBackupData(backupResult: SeedvaultLargeTestResult) {
var packageName: String? = null var packageName: String? = null
val messageDigest = MessageDigest.getInstance("SHA-256") var dataIntercept = ByteArrayOutputStream()
var digestInputStream: DigestInputStream? = null
coEvery { coEvery {
spyFullBackup.performFullBackup(any(), any(), any()) spyFullBackup.performFullBackup(any(), any(), any(), any(), any())
} answers { } answers {
packageName = firstArg<PackageInfo>().packageName packageName = firstArg<PackageInfo>().packageName
callOriginal() callOriginal()
@ -168,19 +166,20 @@ internal interface LargeBackupTestBase : LargeTestBase {
every { every {
spyInputFactory.getInputStream(any()) spyInputFactory.getInputStream(any())
} answers { } answers {
digestInputStream = DigestInputStream(callOriginal(), messageDigest) InputStreamIntercept(
digestInputStream!! inputStream = callOriginal(),
intercept = dataIntercept
)
} }
coEvery { every {
spyFullBackup.finishBackup() spyFullBackup.finishBackup()
} answers { } answers {
val result = callOriginal() val result = callOriginal()
val digest = digestInputStream?.messageDigest ?: fail("No digestInputStream") backupResult.full[packageName!!] = dataIntercept.toByteArray().sha256()
backupResult.full[packageName!!] = digest.digest().toHexString()
packageName = null packageName = null
digest.reset() dataIntercept = ByteArrayOutputStream()
result result
} }
} }
@ -191,18 +190,14 @@ internal interface LargeBackupTestBase : LargeTestBase {
clearMocks(spyBackupNotificationManager) clearMocks(spyBackupNotificationManager)
every { every {
spyBackupNotificationManager.onBackupSuccess(any(), any(), any()) spyBackupNotificationManager.onBackupFinished(any(), any(), any(), any())
} answers { } answers {
val success = firstArg<Boolean>()
assert(success) { "Backup failed." }
callOriginal() callOriginal()
completed.set(true) completed.set(true)
} }
every {
spyBackupNotificationManager.onBackupError()
} answers {
callOriginal()
completed.set(true)
fail("Backup failed.")
}
return completed return completed
} }

View file

@ -8,13 +8,12 @@ package com.stevesoltys.seedvault.e2e
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import com.stevesoltys.seedvault.e2e.io.BackupDataOutputIntercept import com.stevesoltys.seedvault.e2e.io.BackupDataOutputIntercept
import com.stevesoltys.seedvault.e2e.io.OutputStreamIntercept
import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen
import com.stevesoltys.seedvault.e2e.screen.impl.RestoreScreen import com.stevesoltys.seedvault.e2e.screen.impl.RestoreScreen
import com.stevesoltys.seedvault.transport.restore.FullRestore import com.stevesoltys.seedvault.transport.restore.FullRestore
import com.stevesoltys.seedvault.transport.restore.KVRestore import com.stevesoltys.seedvault.transport.restore.KVRestore
import com.stevesoltys.seedvault.transport.restore.OutputFactory import com.stevesoltys.seedvault.transport.restore.OutputFactory
import io.mockk.Call
import io.mockk.MockKAnswerScope
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
@ -23,11 +22,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.calyxos.seedvault.core.toHexString
import org.koin.core.component.get import org.koin.core.component.get
import java.security.DigestOutputStream import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import kotlin.test.fail
internal interface LargeRestoreTestBase : LargeTestBase { internal interface LargeRestoreTestBase : LargeTestBase {
@ -67,6 +63,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
full = mutableMapOf(), full = mutableMapOf(),
kv = mutableMapOf(), kv = mutableMapOf(),
userApps = emptyList(), // will update everything below this after restore userApps = emptyList(), // will update everything below this after restore
userNotAllowedApps = emptyList()
) )
spyOnRestoreData(result) spyOnRestoreData(result)
@ -100,6 +97,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
return result.copy( return result.copy(
userApps = packageService.userApps, userApps = packageService.userApps,
userNotAllowedApps = packageService.userNotAllowedApps
) )
} }
@ -165,26 +163,14 @@ internal interface LargeRestoreTestBase : LargeTestBase {
clearMocks(spyKVRestore) clearMocks(spyKVRestore)
fun initializeStateBlock( coEvery {
packageInfoIndex: Int spyKVRestore.initializeState(any(), any(), any(), any(), any())
): MockKAnswerScope<Unit, Unit>.(Call) -> Unit = { } answers {
packageName = arg<PackageInfo>(packageInfoIndex).packageName packageName = arg<PackageInfo>(3).packageName
restoreResult.kv[packageName!!] = mutableMapOf() restoreResult.kv[packageName!!] = mutableMapOf()
callOriginal() callOriginal()
} }
coEvery {
spyKVRestore.initializeState(any(), any(), any(), any())
} answers initializeStateBlock(1)
coEvery {
spyKVRestore.initializeStateV1(any(), any(), any(), any())
} answers initializeStateBlock(2)
coEvery {
spyKVRestore.initializeStateV0(any(), any())
} answers initializeStateBlock(1)
every { every {
spyOutputFactory.getBackupDataOutput(any()) spyOutputFactory.getBackupDataOutput(any())
} answers { } answers {
@ -198,61 +184,47 @@ internal interface LargeRestoreTestBase : LargeTestBase {
private fun spyOnFullRestoreData(restoreResult: SeedvaultLargeTestResult) { private fun spyOnFullRestoreData(restoreResult: SeedvaultLargeTestResult) {
var packageName: String? = null var packageName: String? = null
val messageDigest = MessageDigest.getInstance("SHA-256") var dataIntercept = ByteArrayOutputStream()
var digestOutputStream: DigestOutputStream? = null
clearMocks(spyFullRestore) clearMocks(spyFullRestore)
fun initializeStateBlock( coEvery {
packageInfoIndex: Int spyFullRestore.initializeState(any(), any(), any(), any())
): MockKAnswerScope<Unit, Unit>.(Call) -> Unit = { } answers {
packageName?.let { packageName?.let {
// sometimes finishRestore() doesn't get called, so get data from last package here restoreResult.full[it] = dataIntercept.toByteArray().sha256()
digestOutputStream?.messageDigest?.let { digest ->
restoreResult.full[packageName!!] = digest.digest().toHexString()
}
} }
packageName = arg<PackageInfo>(packageInfoIndex).packageName packageName = arg<PackageInfo>(3).packageName
dataIntercept = ByteArrayOutputStream()
callOriginal() callOriginal()
} }
coEvery {
spyFullRestore.initializeState(any(), any(), any())
} answers initializeStateBlock(1)
coEvery {
spyFullRestore.initializeStateV1(any(), any(), any())
} answers initializeStateBlock(2)
coEvery {
spyFullRestore.initializeStateV0(any(), any())
} answers initializeStateBlock(1)
every { every {
spyOutputFactory.getOutputStream(any()) spyOutputFactory.getOutputStream(any())
} answers { } answers {
digestOutputStream = DigestOutputStream(callOriginal(), messageDigest) OutputStreamIntercept(
digestOutputStream!! outputStream = callOriginal(),
intercept = dataIntercept
)
} }
every { every {
spyFullRestore.abortFullRestore() spyFullRestore.abortFullRestore()
} answers { } answers {
packageName = null packageName = null
digestOutputStream?.messageDigest?.reset() dataIntercept = ByteArrayOutputStream()
callOriginal() callOriginal()
} }
every { every {
spyFullRestore.finishRestore() spyFullRestore.finishRestore()
} answers { } answers {
val digest = digestOutputStream?.messageDigest ?: fail("No digestOutputStream") restoreResult.full[packageName!!] = dataIntercept.toByteArray().sha256()
restoreResult.full[packageName!!] = digest.digest().toHexString()
packageName = null packageName = null
digest.reset() dataIntercept = ByteArrayOutputStream()
callOriginal() callOriginal()
} }
} }

View file

@ -49,14 +49,14 @@ internal interface LargeTestBase : KoinComponent {
companion object { companion object {
private const val TEST_STORAGE_FOLDER = "seedvault_test" private const val TEST_STORAGE_FOLDER = "seedvault_test"
private const val TEST_RESULT_FOLDER = "seedvault_test_results" private const val TEST_VIDEO_FOLDER = "seedvault_test_results"
} }
val externalStorageDir: String get() = Environment.getExternalStorageDirectory().absolutePath val externalStorageDir: String get() = Environment.getExternalStorageDirectory().absolutePath
val testStoragePath get() = "$externalStorageDir/$TEST_STORAGE_FOLDER" val testStoragePath get() = "$externalStorageDir/$TEST_STORAGE_FOLDER"
val testResultPath get() = "$externalStorageDir/$TEST_RESULT_FOLDER" val testVideoPath get() = "$externalStorageDir/$TEST_VIDEO_FOLDER"
val targetContext: Context val targetContext: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext get() = InstrumentationRegistry.getInstrumentation().targetContext
@ -85,6 +85,7 @@ internal interface LargeTestBase : KoinComponent {
fun resetApplicationState() { fun resetApplicationState() {
backupManager.setAutoRestore(false) backupManager.setAutoRestore(false)
settingsManager.setNewToken(null)
val sharedPreferences = permitDiskReads { val sharedPreferences = permitDiskReads {
PreferenceManager.getDefaultSharedPreferences(targetContext) PreferenceManager.getDefaultSharedPreferences(targetContext)
@ -112,9 +113,11 @@ internal interface LargeTestBase : KoinComponent {
} }
fun testResultFilename(testName: String): String { fun testResultFilename(testName: String): String {
val arguments = InstrumentationRegistry.getArguments()
val d2d = if (arguments.getString("d2d_backup_test") == "true") "d2d" else ""
val simpleDateFormat = SimpleDateFormat("yyyyMMdd_hhmmss") val simpleDateFormat = SimpleDateFormat("yyyyMMdd_hhmmss")
val timeStamp = simpleDateFormat.format(Calendar.getInstance().time) val timeStamp = simpleDateFormat.format(Calendar.getInstance().time)
return "${timeStamp}_${testName.replace(" ", "_")}" return "${timeStamp}_${d2d}_${testName.replace(" ", "_")}"
} }
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
@ -123,7 +126,7 @@ internal interface LargeTestBase : KoinComponent {
keepRecordingScreen: AtomicBoolean, keepRecordingScreen: AtomicBoolean,
testName: String, testName: String,
) { ) {
val folder = testResultPath val folder = testVideoPath
runCommand("mkdir -p $folder") runCommand("mkdir -p $folder")
val fileName = testResultFilename(testName) val fileName = testResultFilename(testName)
@ -149,7 +152,7 @@ internal interface LargeTestBase : KoinComponent {
// write logcat to file // write logcat to file
val fileName = testResultFilename(testName) val fileName = testResultFilename(testName)
runCommand("logcat -d -f $testResultPath/$fileName.log") runCommand("logcat -d -f $testVideoPath/$fileName.log")
} }
fun uninstallPackages(packages: Collection<PackageInfo>) { fun uninstallPackages(packages: Collection<PackageInfo>) {
@ -162,7 +165,7 @@ internal interface LargeTestBase : KoinComponent {
fun clearTestBackups() { fun clearTestBackups() {
File(testStoragePath).deleteRecursively() File(testStoragePath).deleteRecursively()
File(testResultPath).deleteRecursively() File(testVideoPath).deleteRecursively()
} }
fun changeBackupLocation( fun changeBackupLocation(
@ -225,7 +228,6 @@ internal interface LargeTestBase : KoinComponent {
fun confirmCode() { fun confirmCode() {
RecoveryCodeScreen { RecoveryCodeScreen {
startNewBackupButton.click()
confirmCodeButton.click() confirmCodeButton.click()
verifyCodeButton.scrollTo().click() verifyCodeButton.scrollTo().click()

View file

@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.e2e
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
@ -45,11 +46,23 @@ internal abstract class SeedvaultLargeTest :
clearTestBackups() clearTestBackups()
runCommand("bmgr enable true") runCommand("bmgr enable true")
sleep(60_000)
runCommand("bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport") runCommand("bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport")
sleep(5000) sleep(60_000)
startRecordingTest(keepRecordingScreen, name.methodName) startRecordingTest(keepRecordingScreen, name.methodName)
restoreBaselineBackup() restoreBaselineBackup()
val arguments = InstrumentationRegistry.getArguments()
if (arguments.getString("d2d_backup_test") == "true") {
println("Enabling D2D backups for test")
settingsManager.setD2dBackupsEnabled(true)
} else {
println("Disabling D2D backups for test")
settingsManager.setD2dBackupsEnabled(false)
}
} }
@After @After

View file

@ -24,6 +24,7 @@ internal data class SeedvaultLargeTestResult(
val full: MutableMap<String, String>, val full: MutableMap<String, String>,
val kv: MutableMap<String, MutableMap<String, String>>, val kv: MutableMap<String, MutableMap<String, String>>,
val userApps: List<PackageInfo>, val userApps: List<PackageInfo>,
val userNotAllowedApps: List<PackageInfo>,
) { ) {
fun allUserApps() = userApps fun allUserApps() = userApps + userNotAllowedApps
} }

View file

@ -5,14 +5,11 @@
package com.stevesoltys.seedvault.e2e.impl package com.stevesoltys.seedvault.e2e.impl
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.util.Log
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTest import com.stevesoltys.seedvault.e2e.SeedvaultLargeTest
import com.stevesoltys.seedvault.e2e.SeedvaultLargeTestResult import com.stevesoltys.seedvault.e2e.SeedvaultLargeTestResult
import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.transport.backup.isStopped
import org.junit.Test import org.junit.Test
@LargeTest @LargeTest
@ -26,15 +23,12 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
confirmCode() confirmCode()
} }
if (settingsManager.getSafProperties() == null) { if (settingsManager.getSafStorage() == null) {
chooseStorageLocation() chooseStorageLocation()
} else { } else {
changeBackupLocation() changeBackupLocation()
} }
launchStoppedApps()
launchBackupActivity()
val backupResult = performBackup() val backupResult = performBackup()
assertValidBackupMetadata(backupResult) assertValidBackupMetadata(backupResult)
@ -64,28 +58,6 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
} }
} }
private fun launchStoppedApps() {
val packageManager = targetContext.packageManager
val notBackedUp = packageService.notBackedUpPackages
notBackedUp.forEach { packageInfo ->
val i = packageManager.getLaunchIntentForPackage(packageInfo.packageName)?.apply {
addFlags(FLAG_ACTIVITY_NEW_TASK)
}
Log.i("TEST", "Launching $i")
try {
targetContext.startActivity(i)
} catch (e: Exception) {
Log.e("TEST", "Could not launch activity for ${packageInfo.packageName}", e)
}
waitUntilIdle()
}
waitUntilIdle()
notBackedUp.forEach { packageInfo ->
val pi = packageManager.getPackageInfo(packageInfo.packageName, 0)
Log.e("TEST", "${packageInfo.packageName} isStopped: ${pi.isStopped()}")
}
}
private fun assertValidResults( private fun assertValidResults(
backup: SeedvaultLargeTestResult, backup: SeedvaultLargeTestResult,
restore: SeedvaultLargeTestResult, restore: SeedvaultLargeTestResult,

View file

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

View file

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

View file

@ -24,7 +24,7 @@ object BackupScreen : UiDeviceScreen<BackupScreen>() {
val internalStorageButton = findObject { textContains(Build.MODEL) } val internalStorageButton = findObject { textContains(Build.MODEL) }
val useAnywayButton = findObject { text("Use anyway") } val useAnywayButton = findObject { text("USE ANYWAY") }
val initializingText: BySelector = By.textContains("Initializing backup location") val initializingText: BySelector = By.textContains("Initializing backup location")
} }

View file

@ -9,8 +9,6 @@ import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
object RecoveryCodeScreen : UiDeviceScreen<RecoveryCodeScreen>() { object RecoveryCodeScreen : UiDeviceScreen<RecoveryCodeScreen>() {
val startNewBackupButton = findObject { text("Start new") }
val confirmCodeButton = findObject { text("Confirm code") } val confirmCodeButton = findObject { text("Confirm code") }
val verifyCodeButton = findObject { text("Verify") } val verifyCodeButton = findObject { text("Verify") }

View file

@ -9,9 +9,7 @@ import com.stevesoltys.seedvault.e2e.screen.UiDeviceScreen
object RestoreScreen : UiDeviceScreen<RestoreScreen>() { object RestoreScreen : UiDeviceScreen<RestoreScreen>() {
val backupListItem = findObject { val backupListItem = findObject { textContains("Last backup") }
textContains("Android SDK") // device name of test backups
}
val appsSelectedButton = findObject { text("Restore backup") } val appsSelectedButton = findObject { text("Restore backup") }

View file

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

View file

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

View file

@ -9,12 +9,12 @@ import android.content.pm.PackageInfo
import android.util.Log import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.AppStatus import com.stevesoltys.seedvault.settings.AppStatus
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.calyxos.seedvault.core.backends.Backend
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -30,9 +30,9 @@ class PackageServiceTest : KoinComponent {
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val backendManager: BackendManager by inject() private val storagePluginManager: StoragePluginManager by inject()
private val backend: Backend get() = backendManager.backend private val storagePlugin: StoragePlugin<*> get() = storagePluginManager.appPlugin
@Test @Test
fun testNotAllowedPackages() { fun testNotAllowedPackages() {
@ -65,6 +65,6 @@ class PackageServiceTest : KoinComponent {
assertTrue(packageService.shouldIncludeAppInBackup(packageInfo.packageName)) assertTrue(packageService.shouldIncludeAppInBackup(packageInfo.packageName))
// Should not backup storage provider // Should not backup storage provider
assertFalse(packageService.shouldIncludeAppInBackup(backend.providerPackageName!!)) assertFalse(packageService.shouldIncludeAppInBackup(storagePlugin.providerPackageName!!))
} }
} }

View file

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

View file

@ -7,8 +7,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="com.stevesoltys.seedvault" package="com.stevesoltys.seedvault"
android:versionCode="35050000" android:versionCode="34040010"
android:versionName="15-5.0"> android:versionName="14-4.1">
<!-- <!--
The version code is the targeted SDK_VERSION plus 6 digits for our own version code. The version code is the targeted SDK_VERSION plus 6 digits for our own version code.
The version name is the targeted Android version followed by - and our own version name. The version name is the targeted Android version followed by - and our own version name.
@ -101,9 +101,7 @@
<activity <activity
android:name=".settings.SettingsActivity" android:name=".settings.SettingsActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS" />
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS"
android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name=".ui.storage.StorageActivity" android:name=".ui.storage.StorageActivity"
@ -116,14 +114,12 @@
<activity <activity
android:name=".ui.recoverycode.RecoveryCodeActivity" android:name=".ui.recoverycode.RecoveryCodeActivity"
android:label="@string/recovery_code_title" android:label="@string/recovery_code_title" />
android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name=".restore.RestoreActivity" android:name=".restore.RestoreActivity"
android:exported="true" android:exported="true"
android:label="@string/restore_title" android:label="@string/restore_title"
android:launchMode="singleTask"
android:permission="com.stevesoltys.seedvault.RESTORE_BACKUP"> android:permission="com.stevesoltys.seedvault.RESTORE_BACKUP">
<intent-filter> <intent-filter>
<action android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" /> <action android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" />
@ -131,11 +127,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.check.AppCheckResultActivity"
android:label="@string/notification_checking_finished_title"
android:launchMode="singleTask"/>
<service <service
android:name=".transport.ConfigurableBackupTransportService" android:name=".transport.ConfigurableBackupTransportService"
android:exported="false"> android:exported="false">

View file

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

View file

@ -6,8 +6,6 @@
package com.stevesoltys.seedvault package com.stevesoltys.seedvault
import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo
import android.app.Application import android.app.Application
import android.app.backup.BackupManager import android.app.backup.BackupManager
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
@ -19,18 +17,16 @@ import android.os.ServiceManager.getService
import android.os.StrictMode import android.os.StrictMode
import android.os.UserHandle import android.os.UserHandle
import android.os.UserManager import android.os.UserManager
import android.util.Log
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
import androidx.work.WorkManager import androidx.work.WorkManager
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.stevesoltys.seedvault.MemoryLogger.getMemStr
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.saf.storagePluginModuleSaf
import com.stevesoltys.seedvault.backend.webdav.storagePluginModuleWebDav
import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.metadataModule import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.repo.repoModule import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
import com.stevesoltys.seedvault.plugins.webdav.storagePluginModuleWebDav
import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.restore.install.installModule
import com.stevesoltys.seedvault.restore.restoreUiModule import com.stevesoltys.seedvault.restore.restoreUiModule
import com.stevesoltys.seedvault.settings.AppListRetriever import com.stevesoltys.seedvault.settings.AppListRetriever
@ -46,7 +42,6 @@ import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.AppBackupWorker
import com.stevesoltys.seedvault.worker.workerModule import com.stevesoltys.seedvault.worker.workerModule
import org.calyxos.seedvault.core.backends.BackendFactory
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
@ -66,15 +61,7 @@ open class App : Application() {
private val appModule = module { private val appModule = module {
single { SettingsManager(this@App) } single { SettingsManager(this@App) }
single { BackupNotificationManager(this@App) } single { BackupNotificationManager(this@App) }
single { BackendManager(this@App, get(), get(), get()) } single { StoragePluginManager(this@App, get(), get(), get()) }
single {
BackendFactory {
// uses context of the device's main user to be able to access USB storage
this@App.applicationContext.getStorageContext {
get<SettingsManager>().getSafProperties()?.isUsb == true
}
}
}
single { BackupStateManager(this@App) } single { BackupStateManager(this@App) }
single { Clock() } single { Clock() }
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
@ -85,17 +72,16 @@ open class App : Application() {
app = this@App, app = this@App,
settingsManager = get(), settingsManager = get(),
keyManager = get(), keyManager = get(),
backendManager = get(), pluginManager = get(),
metadataManager = get(),
appListRetriever = get(), appListRetriever = get(),
storageBackup = get(), storageBackup = get(),
backupManager = get(), backupManager = get(),
backupInitializer = get(),
backupStateManager = get(), backupStateManager = get(),
checker = get(),
) )
} }
viewModel { viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get(), get())
}
viewModel { viewModel {
BackupStorageViewModel( BackupStorageViewModel(
app = this@App, app = this@App,
@ -105,7 +91,7 @@ open class App : Application() {
safHandler = get(), safHandler = get(),
webDavHandler = get(), webDavHandler = get(),
settingsManager = get(), settingsManager = get(),
backendManager = get(), storagePluginManager = get(),
) )
} }
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
@ -115,7 +101,6 @@ open class App : Application() {
super.onCreate() super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this) DynamicColors.applyToActivitiesIfAvailable(this)
startKoin() startKoin()
if (!isTest) migrateToOwnScheduling()
if (isDebugBuild()) { if (isDebugBuild()) {
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()
@ -131,6 +116,10 @@ open class App : Application() {
.build() .build()
) )
} }
permitDiskReads {
migrateTokenFromMetadataToSettingsManager()
}
if (!isTest) migrateToOwnScheduling()
} }
protected open fun startKoin() = startKoin { protected open fun startKoin() = startKoin {
@ -149,24 +138,29 @@ open class App : Application() {
restoreModule, restoreModule,
installModule, installModule,
storageModule, storageModule,
repoModule,
workerModule, workerModule,
restoreUiModule, restoreUiModule,
appModule appModule
) )
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val metadataManager: MetadataManager by inject()
private val backupManager: IBackupManager by inject() private val backupManager: IBackupManager by inject()
private val backendManager: BackendManager by inject() private val pluginManager: StoragePluginManager by inject()
private val backupStateManager: BackupStateManager by inject() private val backupStateManager: BackupStateManager by inject()
override fun onTrimMemory(level: Int) { /**
Log.w("Seedvault", "onTrimMemory($level) ${getMemStr()}") * The responsibility for the current token was moved to the [SettingsManager]
val processInfo = RunningAppProcessInfo() * in the end of 2020.
ActivityManager.getMyMemoryState(processInfo) * This method migrates the token for existing installs and can be removed
Log.w("Seedvault", " lastTrimLevel: ${processInfo.lastTrimLevel}") * after sufficient time has passed.
Log.w("Seedvault", " importance: ${processInfo.importance}") */
super.onTrimMemory(level) private fun migrateTokenFromMetadataToSettingsManager() {
@Suppress("DEPRECATION")
val token = metadataManager.getBackupToken()
if (token != 0L && settingsManager.getToken() == null) {
settingsManager.setNewToken(token)
}
} }
/** /**
@ -176,13 +170,13 @@ open class App : Application() {
protected open fun migrateToOwnScheduling() { protected open fun migrateToOwnScheduling() {
if (!backupStateManager.isFrameworkSchedulingEnabled) { // already on own scheduling if (!backupStateManager.isFrameworkSchedulingEnabled) { // already on own scheduling
// fix things for removable drive users who had a job scheduled here before // fix things for removable drive users who had a job scheduled here before
if (backendManager.isOnRemovableDrive) AppBackupWorker.unschedule(applicationContext) if (pluginManager.isOnRemovableDrive) AppBackupWorker.unschedule(applicationContext)
return return
} }
if (backupManager.currentTransport == TRANSPORT_ID) { if (backupManager.currentTransport == TRANSPORT_ID) {
backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false) backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), false)
if (backupManager.isBackupEnabled && !backendManager.isOnRemovableDrive) { if (backupManager.isBackupEnabled && !pluginManager.isOnRemovableDrive) {
AppBackupWorker.schedule(applicationContext, settingsManager, UPDATE) AppBackupWorker.schedule(applicationContext, settingsManager, UPDATE)
} }
// cancel old D2D worker // cancel old D2D worker
@ -197,7 +191,6 @@ const val ANCESTRAL_RECORD_KEY = "@ancestral_record@"
const val NO_DATA_END_SENTINEL = "@end@" const val NO_DATA_END_SENTINEL = "@end@"
const val GLOBAL_METADATA_KEY = "@meta@" const val GLOBAL_METADATA_KEY = "@meta@"
const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED const val ERROR_BACKUP_CANCELLED: Int = BackupManager.ERROR_BACKUP_CANCELLED
const val ERROR_BACKUP_NOT_ALLOWED: Int = BackupManager.ERROR_BACKUP_NOT_ALLOWED
// TODO this doesn't work for LineageOS as they do public debug builds // TODO this doesn't work for LineageOS as they do public debug builds
fun isDebugBuild() = Build.TYPE == "userdebug" fun isDebugBuild() = Build.TYPE == "userdebug"
@ -220,10 +213,6 @@ fun <T> permitDiskReads(func: () -> T): T {
} }
} }
/**
* Hack to allow other profiles access to USB backend.
* @return the context of the device's main user, so use with great care!
*/
@Suppress("MissingPermission") @Suppress("MissingPermission")
fun Context.getStorageContext(isUsbStorage: () -> Boolean): Context { fun Context.getStorageContext(isUsbStorage: () -> Boolean): Context {
if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED && isUsbStorage()) { if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED && isUsbStorage()) {

View file

@ -13,26 +13,23 @@ import android.app.backup.BackupManagerMonitor.LOG_EVENT_ID_ERROR_PREFLIGHT
import android.app.backup.IBackupManagerMonitor import android.app.backup.IBackupManagerMonitor
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import android.util.Log.DEBUG
private val TAG = BackupMonitor::class.java.name private val TAG = BackupMonitor::class.java.name
open class BackupMonitor : IBackupManagerMonitor.Stub() { class BackupMonitor : IBackupManagerMonitor.Stub() {
override fun onEvent(bundle: Bundle) { override fun onEvent(bundle: Bundle) {
onEvent( val id = bundle.getInt(EXTRA_LOG_EVENT_ID)
id = bundle.getInt(EXTRA_LOG_EVENT_ID), val packageName = bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME, "?")
category = bundle.getInt(EXTRA_LOG_EVENT_CATEGORY),
packageName = bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME),
bundle = bundle,
)
}
open fun onEvent(id: Int, category: Int, packageName: String?, bundle: Bundle) {
Log.d(TAG, "${packageName?.padEnd(64, ' ')} cat: $category id: $id")
if (id == LOG_EVENT_ID_ERROR_PREFLIGHT) { if (id == LOG_EVENT_ID_ERROR_PREFLIGHT) {
val preflightResult = bundle.getLong(EXTRA_LOG_PREFLIGHT_ERROR, -1) val preflightResult = bundle.getLong(EXTRA_LOG_PREFLIGHT_ERROR, -1)
Log.w(TAG, "Pre-flight error from $packageName: $preflightResult") Log.w(TAG, "Pre-flight error from $packageName: $preflightResult")
} }
if (!Log.isLoggable(TAG, DEBUG)) return
Log.d(TAG, "ID: $id")
Log.d(TAG, "CATEGORY: " + bundle.getInt(EXTRA_LOG_EVENT_CATEGORY, -1))
Log.d(TAG, "PACKAGE: $packageName")
} }
} }

View file

@ -14,9 +14,7 @@ import androidx.work.WorkInfo.State.RUNNING
import androidx.work.WorkManager import androidx.work.WorkManager
import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService
import com.stevesoltys.seedvault.worker.AppBackupPruneWorker
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
import com.stevesoltys.seedvault.worker.AppCheckerWorker
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -33,28 +31,14 @@ class BackupStateManager(
flow = ConfigurableBackupTransportService.isRunning, flow = ConfigurableBackupTransportService.isRunning,
flow2 = StorageBackupService.isRunning, flow2 = StorageBackupService.isRunning,
flow3 = workManager.getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME), flow3 = workManager.getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME),
) { appBackupRunning, filesBackupRunning, workInfo1 -> ) { appBackupRunning, filesBackupRunning, workInfos ->
val workInfoState1 = workInfo1.getOrNull(0)?.state val workInfoState = workInfos.getOrNull(0)?.state
Log.i( Log.i(
TAG, "appBackupRunning: $appBackupRunning, " + TAG, "appBackupRunning: $appBackupRunning, " +
"filesBackupRunning: $filesBackupRunning, " + "filesBackupRunning: $filesBackupRunning, " +
"appBackupWorker: ${workInfoState1?.name}" "workInfoState: ${workInfoState?.name}"
) )
appBackupRunning || filesBackupRunning || workInfoState1 == RUNNING appBackupRunning || filesBackupRunning || workInfoState == RUNNING
}
val isCheckOrPruneRunning: Flow<Boolean> = combine(
flow = workManager.getWorkInfosForUniqueWorkFlow(AppBackupPruneWorker.UNIQUE_WORK_NAME),
flow2 = workManager.getWorkInfosForUniqueWorkFlow(AppCheckerWorker.UNIQUE_WORK_NAME),
) { pruneInfo, checkInfo ->
val pruneInfoState = pruneInfo.getOrNull(0)?.state
val checkInfoState = checkInfo.getOrNull(0)?.state
Log.i(
TAG,
"pruneBackupWorker: ${pruneInfoState?.name}, " +
"appCheckerWorker: ${checkInfoState?.name}"
)
pruneInfoState == RUNNING || checkInfoState == RUNNING
} }
val isAutoRestoreEnabled: Boolean val isAutoRestoreEnabled: Boolean

View file

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

View file

@ -20,10 +20,14 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.util.Log import android.util.Log
import androidx.core.content.ContextCompat.startForegroundService
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.FlashDrive import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.storage.StorageBackupService
import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import com.stevesoltys.seedvault.worker.BackupRequester.Companion.requestFilesAndAppBackup import com.stevesoltys.seedvault.worker.AppBackupWorker
import org.koin.core.context.GlobalContext.get import org.koin.core.context.GlobalContext.get
import java.util.Date import java.util.Date
@ -33,6 +37,7 @@ class UsbIntentReceiver : UsbMonitor() {
// using KoinComponent would crash robolectric tests :( // using KoinComponent would crash robolectric tests :(
private val settingsManager: SettingsManager by lazy { get().get() } private val settingsManager: SettingsManager by lazy { get().get() }
private val metadataManager: MetadataManager by lazy { get().get() }
private val backupManager: IBackupManager by lazy { get().get() } private val backupManager: IBackupManager by lazy { get().get() }
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean { override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
@ -42,15 +47,14 @@ class UsbIntentReceiver : UsbMonitor() {
val attachedFlashDrive = FlashDrive.from(device) val attachedFlashDrive = FlashDrive.from(device)
return if (savedFlashDrive == attachedFlashDrive) { return if (savedFlashDrive == attachedFlashDrive) {
Log.d(TAG, "Matches stored device, checking backup time...") Log.d(TAG, "Matches stored device, checking backup time...")
val lastBackupTime = settingsManager.lastBackupTime.value ?: 0 val backupMillis = System.currentTimeMillis() - metadataManager.getLastBackupTime()
val backupMillis = System.currentTimeMillis() - lastBackupTime
if (backupMillis >= settingsManager.backupFrequencyInMillis) { if (backupMillis >= settingsManager.backupFrequencyInMillis) {
Log.d(TAG, "Last backup older than it should be, requesting a backup...") Log.d(TAG, "Last backup older than it should be, requesting a backup...")
Log.d(TAG, " ${Date(lastBackupTime)}") Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
true true
} else { } else {
Log.d(TAG, "We have a recent backup, not requesting a new one.") Log.d(TAG, "We have a recent backup, not requesting a new one.")
Log.d(TAG, " ${Date(lastBackupTime)}") Log.d(TAG, " ${Date(metadataManager.getLastBackupTime())}")
false false
} }
} else { } else {
@ -60,7 +64,16 @@ class UsbIntentReceiver : UsbMonitor() {
} }
override fun onStatusChanged(context: Context, action: String, device: UsbDevice) { override fun onStatusChanged(context: Context, action: String, device: UsbDevice) {
requestFilesAndAppBackup(context, settingsManager, backupManager) if (settingsManager.isStorageBackupEnabled()) {
val i = Intent(context, StorageBackupService::class.java)
// this starts an app backup afterwards
i.putExtra(EXTRA_START_APP_BACKUP, true)
startForegroundService(context, i)
} else if (backupManager.isBackupEnabled) {
AppBackupWorker.scheduleNow(context, reschedule = false)
} else {
Log.d(TAG, "Neither files nor app backup enabled, do nothing.")
}
} }
} }
@ -76,7 +89,7 @@ abstract class UsbMonitor : BroadcastReceiver() {
if (intent.action == ACTION_USB_DEVICE_ATTACHED || if (intent.action == ACTION_USB_DEVICE_ATTACHED ||
intent.action == ACTION_USB_DEVICE_DETACHED intent.action == ACTION_USB_DEVICE_DETACHED
) { ) {
val device = intent.extras?.getParcelable(EXTRA_DEVICE, UsbDevice::class.java) ?: return val device = intent.extras?.getParcelable<UsbDevice>(EXTRA_DEVICE) ?: return
Log.d(TAG, "New USB mass-storage device attached.") Log.d(TAG, "New USB mass-storage device attached.")
device.log() device.log()

View file

@ -5,33 +5,23 @@
package com.stevesoltys.seedvault.crypto package com.stevesoltys.seedvault.crypto
import android.annotation.SuppressLint
import android.content.Context
import android.provider.Settings
import android.provider.Settings.Secure.ANDROID_ID
import com.google.crypto.tink.subtle.AesGcmHkdfStreaming import com.google.crypto.tink.subtle.AesGcmHkdfStreaming
import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.HeaderReader import com.stevesoltys.seedvault.header.HeaderReader
import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
import com.stevesoltys.seedvault.header.MAX_VERSION_HEADER_SIZE import com.stevesoltys.seedvault.header.MAX_VERSION_HEADER_SIZE
import com.stevesoltys.seedvault.header.SegmentHeader import com.stevesoltys.seedvault.header.SegmentHeader
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.VersionHeader import com.stevesoltys.seedvault.header.VersionHeader
import org.calyxos.seedvault.core.crypto.CoreCrypto import org.calyxos.backup.storage.crypto.StreamCrypto
import org.calyxos.seedvault.core.crypto.CoreCrypto.ALGORITHM_HMAC import org.calyxos.backup.storage.crypto.StreamCrypto.deriveStreamKey
import org.calyxos.seedvault.core.crypto.CoreCrypto.deriveKey
import org.calyxos.seedvault.core.toByteArrayFromHex
import org.calyxos.seedvault.core.toHexString
import java.io.EOFException import java.io.EOFException
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.nio.ByteBuffer
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.security.MessageDigest import java.security.MessageDigest
import java.security.NoSuchAlgorithmException import java.security.NoSuchAlgorithmException
import java.security.SecureRandom import java.security.SecureRandom
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
/** /**
@ -57,18 +47,13 @@ internal interface Crypto {
*/ */
fun getRandomBytes(size: Int): ByteArray fun getRandomBytes(size: Int): ByteArray
/** fun getNameForPackage(salt: String, packageName: String): String
* Returns the ID of the backup repository as a 64 char hex string.
*/
val repoId: String
/** /**
* A secret key of size [KEY_SIZE_BYTES] * Returns the name that identifies an APK in the backup storage plugin.
* only used to create a gear table specific to each main key. * @param suffix empty string for normal APKs and the name of the split in case of an APK split
*/ */
val gearTableKey: ByteArray fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String
fun sha256(bytes: ByteArray): ByteArray
/** /**
* Returns a [AesGcmHkdfStreaming] encrypting stream * Returns a [AesGcmHkdfStreaming] encrypting stream
@ -90,29 +75,6 @@ internal interface Crypto {
associatedData: ByteArray, associatedData: ByteArray,
): InputStream ): InputStream
fun getAdForVersion(version: Byte = VERSION): ByteArray
@Deprecated("only for v1")
fun getNameForPackage(salt: String, packageName: String): String
/**
* Returns the name that identifies an APK in the backup storage plugin.
* @param suffix empty string for normal APKs and the name of the split in case of an APK split
*/
@Deprecated("only for v1")
fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String
/**
* Returns a [AesGcmHkdfStreaming] decrypting stream
* that gets decrypted and authenticated the given associated data.
*/
@Deprecated("only for v1")
@Throws(IOException::class, GeneralSecurityException::class)
fun newDecryptingStreamV1(
inputStream: InputStream,
associatedData: ByteArray,
): InputStream
/** /**
* Reads and decrypts a [VersionHeader] from the given [InputStream] * Reads and decrypts a [VersionHeader] from the given [InputStream]
* and ensures that the expected version, package name and key match * and ensures that the expected version, package name and key match
@ -160,71 +122,30 @@ internal const val TYPE_BACKUP_KV: Byte = 0x01
internal const val TYPE_BACKUP_FULL: Byte = 0x02 internal const val TYPE_BACKUP_FULL: Byte = 0x02
internal const val TYPE_ICONS: Byte = 0x03 internal const val TYPE_ICONS: Byte = 0x03
@SuppressLint("HardwareIds")
internal class CryptoImpl( internal class CryptoImpl(
context: Context,
private val keyManager: KeyManager, private val keyManager: KeyManager,
private val cipherFactory: CipherFactory, private val cipherFactory: CipherFactory,
private val headerReader: HeaderReader, private val headerReader: HeaderReader,
private val androidId: String = Settings.Secure.getString(context.contentResolver, ANDROID_ID),
) : Crypto { ) : Crypto {
private val keyV1: ByteArray by lazy { private val key: ByteArray by lazy {
deriveKey(keyManager.getMainKey(), "app data key".toByteArray()) deriveStreamKey(keyManager.getMainKey(), "app data key".toByteArray())
} }
private val streamKey: ByteArray by lazy { private val secureRandom: SecureRandom by lazy { SecureRandom() }
deriveKey(keyManager.getMainKey(), "app backup stream key".toByteArray())
}
private val secureRandom: SecureRandom by lazy { SecureRandom.getInstanceStrong() }
override fun getRandomBytes(size: Int) = ByteArray(size).apply { override fun getRandomBytes(size: Int) = ByteArray(size).apply {
secureRandom.nextBytes(this) secureRandom.nextBytes(this)
} }
/**
* The ID of the backup repository tied to this user/device via [ANDROID_ID]
* and the current [KeyManager.getMainKey].
*
* Attention: If the main key ever changes, we need to kill our process,
* so all lazy values that depend on that key or the [gearTableKey] get reinitialized.
*/
override val repoId: String by lazy {
val repoIdKey =
deriveKey(keyManager.getMainKey(), "app backup repoId key".toByteArray())
val hmacHasher: Mac = Mac.getInstance(ALGORITHM_HMAC).apply {
init(SecretKeySpec(repoIdKey, ALGORITHM_HMAC))
}
hmacHasher.doFinal(androidId.toByteArrayFromHex()).toHexString()
}
override val gearTableKey: ByteArray
get() = deriveKey(keyManager.getMainKey(), "app backup gear table key".toByteArray())
override fun newEncryptingStream(
outputStream: OutputStream,
associatedData: ByteArray,
): OutputStream = CoreCrypto.newEncryptingStream(streamKey, outputStream, associatedData)
override fun newDecryptingStream(
inputStream: InputStream,
associatedData: ByteArray,
): InputStream = CoreCrypto.newDecryptingStream(streamKey, inputStream, associatedData)
override fun getAdForVersion(version: Byte): ByteArray = ByteBuffer.allocate(1)
.put(version)
.array()
@Deprecated("only for v1")
override fun getNameForPackage(salt: String, packageName: String): String { override fun getNameForPackage(salt: String, packageName: String): String {
return sha256("$salt$packageName".toByteArray()).encodeBase64() return sha256("$salt$packageName".toByteArray()).encodeBase64()
} }
@Deprecated("only for v1")
override fun getNameForApk(salt: String, packageName: String, suffix: String): String { override fun getNameForApk(salt: String, packageName: String, suffix: String): String {
return sha256("${salt}APK$packageName$suffix".toByteArray()).encodeBase64() return sha256("${salt}APK$packageName$suffix".toByteArray()).encodeBase64()
} }
override fun sha256(bytes: ByteArray): ByteArray { private fun sha256(bytes: ByteArray): ByteArray {
val messageDigest: MessageDigest = try { val messageDigest: MessageDigest = try {
MessageDigest.getInstance("SHA-256") MessageDigest.getInstance("SHA-256")
} catch (e: NoSuchAlgorithmException) { } catch (e: NoSuchAlgorithmException) {
@ -234,12 +155,21 @@ internal class CryptoImpl(
return messageDigest.digest() return messageDigest.digest()
} }
@Deprecated("only for v1")
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
override fun newDecryptingStreamV1( override fun newEncryptingStream(
outputStream: OutputStream,
associatedData: ByteArray,
): OutputStream {
return StreamCrypto.newEncryptingStream(key, outputStream, associatedData)
}
@Throws(IOException::class, GeneralSecurityException::class)
override fun newDecryptingStream(
inputStream: InputStream, inputStream: InputStream,
associatedData: ByteArray, associatedData: ByteArray,
): InputStream = CoreCrypto.newDecryptingStream(keyV1, inputStream, associatedData) ): InputStream {
return StreamCrypto.newDecryptingStream(key, inputStream, associatedData)
}
@Suppress("Deprecation") @Suppress("Deprecation")
@Throws(IOException::class, SecurityException::class) @Throws(IOException::class, SecurityException::class)

View file

@ -5,7 +5,6 @@
package com.stevesoltys.seedvault.crypto package com.stevesoltys.seedvault.crypto
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
import java.security.KeyStore import java.security.KeyStore
@ -21,5 +20,5 @@ val cryptoModule = module {
} }
KeyManagerImpl(keyStore) KeyManagerImpl(keyStore)
} }
single<Crypto> { CryptoImpl(androidContext(), get(), get(), get()) } single<Crypto> { CryptoImpl(get(), get(), get()) }
} }

View file

@ -24,7 +24,7 @@ internal const val KEY_ALIAS_MAIN = "com.stevesoltys.seedvault.main"
private const val KEY_ALGORITHM_BACKUP = "AES" private const val KEY_ALGORITHM_BACKUP = "AES"
private const val KEY_ALGORITHM_MAIN = "HmacSHA256" private const val KEY_ALGORITHM_MAIN = "HmacSHA256"
interface KeyManager : org.calyxos.seedvault.core.crypto.KeyManager { interface KeyManager {
/** /**
* Store a new backup key derived from the given [seed]. * Store a new backup key derived from the given [seed].
* *
@ -57,6 +57,14 @@ interface KeyManager : org.calyxos.seedvault.core.crypto.KeyManager {
* because the key can not leave the [KeyStore]'s hardware security module. * because the key can not leave the [KeyStore]'s hardware security module.
*/ */
fun getBackupKey(): SecretKey fun getBackupKey(): SecretKey
/**
* Returns the main key, so it can be used for deriving sub-keys.
*
* Note that any attempt to export the key will return null or an empty [ByteArray],
* because the key can not leave the [KeyStore]'s hardware security module.
*/
fun getMainKey(): SecretKey
} }
internal class KeyManagerImpl( internal class KeyManagerImpl(

View file

@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_FULL
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
import java.nio.ByteBuffer import java.nio.ByteBuffer
internal const val VERSION: Byte = 2 internal const val VERSION: Byte = 1
internal const val MAX_PACKAGE_LENGTH_SIZE = 255 internal const val MAX_PACKAGE_LENGTH_SIZE = 255
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
internal const val MAX_VERSION_HEADER_SIZE = internal const val MAX_VERSION_HEADER_SIZE =

View file

@ -8,12 +8,8 @@ package com.stevesoltys.seedvault.metadata
import android.content.pm.ApplicationInfo.FLAG_STOPPED import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.os.Build import android.os.Build
import com.stevesoltys.seedvault.crypto.TYPE_METADATA import com.stevesoltys.seedvault.crypto.TYPE_METADATA
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.repo.hexFromProto
import com.stevesoltys.seedvault.worker.BASE_SPLIT
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
import java.nio.ByteBuffer import java.nio.ByteBuffer
@ -30,23 +26,6 @@ data class BackupMetadata(
internal var d2dBackup: Boolean = false, internal var d2dBackup: Boolean = false,
internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(), internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(),
) { ) {
companion object {
fun fromSnapshot(s: Snapshot) = BackupMetadata(
version = s.version.toByte(),
token = s.token,
salt = "",
time = s.token,
androidVersion = s.sdkInt,
androidIncremental = s.androidIncremental,
deviceName = "${s.name} - ${s.user}",
d2dBackup = s.d2D,
packageMetadataMap = s.appsMap.mapValues { (_, app) ->
PackageMetadata.fromSnapshot(app)
} as PackageMetadataMap
)
}
val size: Long val size: Long
get() = packageMetadataMap.values.sumOf { m -> get() = packageMetadataMap.values.sumOf { m ->
(m.size ?: 0L) + (m.splits?.sumOf { it.size ?: 0L } ?: 0L) (m.size ?: 0L) + (m.splits?.sumOf { it.size ?: 0L } ?: 0L)
@ -112,56 +91,12 @@ data class PackageMetadata(
internal val version: Long? = null, internal val version: Long? = null,
internal val installer: String? = null, internal val installer: String? = null,
internal val splits: List<ApkSplit>? = null, internal val splits: List<ApkSplit>? = null,
internal val baseApkChunkIds: List<String>? = null, // used for v2
internal val chunkIds: List<String>? = null, // used for v2
internal val sha256: String? = null, internal val sha256: String? = null,
internal val signatures: List<String>? = null, internal val signatures: List<String>? = null,
) { ) {
companion object {
fun fromSnapshot(app: Snapshot.App) = PackageMetadata(
time = app.time,
backupType = app.type.toBackupType(),
name = app.name,
chunkIds = app.chunkIdsList.hexFromProto(),
system = app.system,
isLaunchableSystemApp = app.launchableSystemApp,
version = app.apk.versionCode,
installer = app.apk.installer.takeIf { it.isNotEmpty() },
baseApkChunkIds = run {
val baseChunk = app.apk.splitsList.find { it.name == BASE_SPLIT }
if (baseChunk == null || baseChunk.chunkIdsCount == 0) {
null
} else {
baseChunk.chunkIdsList.hexFromProto()
}
},
splits = app.apk.splitsList.filter { it.name != BASE_SPLIT }.map {
ApkSplit(
name = it.name,
size = null,
sha256 = "",
chunkIds = if (it.chunkIdsCount == 0) null else it.chunkIdsList.hexFromProto()
)
}.takeIf { it.isNotEmpty() }, // expected null if there are no splits
sha256 = null,
signatures = app.apk.signaturesList.map { it.toByteArray().encodeBase64() }.takeIf {
it.isNotEmpty()
},
)
fun Snapshot.BackupType.toBackupType() = when (this) {
Snapshot.BackupType.FULL -> BackupType.FULL
Snapshot.BackupType.KV -> BackupType.KV
else -> null
}
}
val isInternalSystem: Boolean = system && !isLaunchableSystemApp val isInternalSystem: Boolean = system && !isLaunchableSystemApp
fun hasApk(): Boolean { fun hasApk(): Boolean {
return version != null && // v2 doesn't use sha256 here return version != null && sha256 != null && signatures != null
(sha256 != null || baseApkChunkIds?.isNotEmpty() == true) &&
signatures != null
} }
} }
@ -169,7 +104,6 @@ data class ApkSplit(
val name: String, val name: String,
val size: Long?, val size: Long?,
val sha256: String, val sha256: String,
val chunkIds: List<String>? = null, // used for v2
// There's also a revisionCode, but it doesn't seem to be used just yet // There's also a revisionCode, but it doesn't seem to be used just yet
) )

View file

@ -8,11 +8,23 @@ package com.stevesoltys.seedvault.metadata
import android.content.Context import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build
import android.os.UserManager
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.distinctUntilChanged
import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.backup.isSystemApp
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
@ -27,50 +39,130 @@ internal const val METADATA_SALT_SIZE = 32
internal class MetadataManager( internal class MetadataManager(
private val context: Context, private val context: Context,
private val clock: Clock, private val clock: Clock,
private val crypto: Crypto,
private val metadataWriter: MetadataWriter, private val metadataWriter: MetadataWriter,
private val metadataReader: MetadataReader, private val metadataReader: MetadataReader,
private val packageService: PackageService,
private val settingsManager: SettingsManager,
) { ) {
private val uninitializedMetadata = BackupMetadata(token = -42L, salt = "foo bar") private val uninitializedMetadata = BackupMetadata(token = 0L, salt = "")
private var metadata: BackupMetadata = uninitializedMetadata private var metadata: BackupMetadata = uninitializedMetadata
get() { get() {
if (field == uninitializedMetadata) { if (field == uninitializedMetadata) {
field = try { field = try {
val m = getMetadataFromCache() ?: throw IOException() getMetadataFromCache() ?: throw IOException()
if (m == uninitializedMetadata) m.copy(salt = "initialized")
else m
} catch (e: IOException) { } catch (e: IOException) {
// This can happen if the storage location ran out of space // This can happen if the storage location ran out of space
// or the app process got killed while writing the file. // or the app process got killed while writing the file.
// It is hard to recover from this, so we try as best as we can here: // It is hard to recover from this, so we try as best as we can here:
Log.e(TAG, "ERROR getting metadata cache, creating new file ", e) Log.e(TAG, "ERROR getting metadata cache, creating new file ", e)
uninitializedMetadata.copy(salt = "initialized") // This should cause requiresInit() return true
uninitializedMetadata.copy(version = (-1).toByte())
} }
mLastBackupTime.postValue(field.time)
} }
return field return field
} }
val backupSize: Long get() = metadata.size
private val launchableSystemApps by lazy {
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
}
/**
* Call this when initializing a new device.
*
* Existing [BackupMetadata] will be cleared
* and new metadata with the given [token] will be written to the internal cache
* with a fresh salt.
*/
@Synchronized
@Throws(IOException::class)
fun onDeviceInitialization(token: Long) {
val salt = crypto.getRandomBytes(METADATA_SALT_SIZE).encodeBase64()
modifyCachedMetadata {
val userName = getUserName()
metadata = BackupMetadata(
token = token,
salt = salt,
deviceName = if (userName == null) {
"${Build.MANUFACTURER} ${Build.MODEL}"
} else {
"${Build.MANUFACTURER} ${Build.MODEL} - $userName"
},
)
}
}
/**
* Call this after a package's APK has been backed up successfully.
*
* It updates the packages' metadata to the internal cache.
* You still need to call [uploadMetadata] to persist all local modifications.
*/
@Synchronized
@Throws(IOException::class)
fun onApkBackedUp(
packageInfo: PackageInfo,
packageMetadata: PackageMetadata,
) {
val packageName = packageInfo.packageName
metadata.packageMetadataMap[packageName]?.let {
check(packageMetadata.version != null) {
"APK backup returned version null"
}
}
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
?: PackageMetadata()
modifyCachedMetadata {
val isSystemApp = packageInfo.isSystemApp()
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
system = isSystemApp,
isLaunchableSystemApp = isSystemApp && launchableSystemApps.contains(packageName),
version = packageMetadata.version,
installer = packageMetadata.installer,
splits = packageMetadata.splits,
sha256 = packageMetadata.sha256,
signatures = packageMetadata.signatures
)
}
}
/** /**
* Call this after a package has been backed up successfully. * Call this after a package has been backed up successfully.
* *
* It updates the packages' metadata. * It updates the packages' metadata
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
*
* Closing the [OutputStream] is the responsibility of the caller.
*/ */
@Synchronized @Synchronized
@Throws(IOException::class) @Throws(IOException::class)
fun onPackageBackedUp( fun onPackageBackedUp(
packageInfo: PackageInfo, packageInfo: PackageInfo,
type: BackupType?, type: BackupType,
size: Long?, size: Long?,
metadataOutputStream: OutputStream,
) { ) {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
modifyCachedMetadata { modifyMetadata(metadataOutputStream) {
val now = clock.time() val now = clock.time()
metadata.time = now
metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
metadata.packageMetadataMap.getOrPut(packageName) { metadata.packageMetadataMap.getOrPut(packageName) {
val isSystemApp = packageInfo.isSystemApp()
PackageMetadata( PackageMetadata(
time = now, time = now,
state = APK_AND_DATA, state = APK_AND_DATA,
backupType = type, backupType = type,
size = size, size = size,
name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
system = isSystemApp,
isLaunchableSystemApp = isSystemApp &&
launchableSystemApps.contains(packageName),
) )
}.apply { }.apply {
time = now time = now
@ -78,6 +170,10 @@ internal class MetadataManager(
backupType = type backupType = type
// don't override a previous K/V size, if there were no K/V changes // don't override a previous K/V size, if there were no K/V changes
if (size != null) this.size = size if (size != null) this.size = size
// update name, if none was set, yet (can happen while migrating to storing names)
if (this.name == null) {
this.name = packageInfo.applicationInfo?.loadLabel(context.packageManager)
}
} }
} }
} }
@ -93,16 +189,21 @@ internal class MetadataManager(
internal fun onPackageBackupError( internal fun onPackageBackupError(
packageInfo: PackageInfo, packageInfo: PackageInfo,
packageState: PackageState, packageState: PackageState,
metadataOutputStream: OutputStream,
backupType: BackupType? = null, backupType: BackupType? = null,
) { ) {
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." } check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
modifyCachedMetadata { modifyMetadata(metadataOutputStream) {
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) { metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
val isSystemApp = packageInfo.isSystemApp()
PackageMetadata( PackageMetadata(
time = 0L, time = 0L,
state = packageState, state = packageState,
backupType = backupType, backupType = backupType,
name = packageInfo.applicationInfo?.loadLabel(context.packageManager), name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
system = isSystemApp,
isLaunchableSystemApp = isSystemApp &&
launchableSystemApps.contains(packageInfo.packageName),
) )
}.state = packageState }.state = packageState
} }
@ -112,6 +213,7 @@ internal class MetadataManager(
* Call this for all packages we can not back up for some reason. * Call this for all packages we can not back up for some reason.
* *
* It updates the packages' local metadata. * It updates the packages' local metadata.
* You still need to call [uploadMetadata] to persist all local modifications.
*/ */
@Synchronized @Synchronized
@Throws(IOException::class) @Throws(IOException::class)
@ -120,10 +222,14 @@ internal class MetadataManager(
packageState: PackageState, packageState: PackageState,
) = modifyCachedMetadata { ) = modifyCachedMetadata {
metadata.packageMetadataMap.getOrPut(packageInfo.packageName) { metadata.packageMetadataMap.getOrPut(packageInfo.packageName) {
val isSystemApp = packageInfo.isSystemApp()
PackageMetadata( PackageMetadata(
time = 0L, time = 0L,
state = packageState, state = packageState,
name = packageInfo.applicationInfo?.loadLabel(context.packageManager), name = packageInfo.applicationInfo?.loadLabel(context.packageManager),
system = isSystemApp,
isLaunchableSystemApp = isSystemApp &&
launchableSystemApps.contains(packageInfo.packageName),
) )
}.apply { }.apply {
state = packageState state = packageState
@ -134,15 +240,18 @@ internal class MetadataManager(
} }
} }
/**
* Uploads metadata to given [metadataOutputStream] after performing local modifications.
*/
@Synchronized @Synchronized
fun getPackageMetadata(packageName: String): PackageMetadata? { @Throws(IOException::class)
return metadata.packageMetadataMap[packageName]?.copy() fun uploadMetadata(metadataOutputStream: OutputStream) {
metadataWriter.write(metadata, metadataOutputStream)
} }
@Throws(IOException::class) @Throws(IOException::class)
private fun modifyCachedMetadata(modFun: () -> Unit) { private fun modifyCachedMetadata(modFun: () -> Unit) {
val oldMetadata = metadata.copy( val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
// copy map, otherwise it will re-use same reference
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap), packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
) )
try { try {
@ -156,6 +265,63 @@ internal class MetadataManager(
} }
} }
@Throws(IOException::class)
private fun modifyMetadata(metadataOutputStream: OutputStream, modFun: () -> Unit) {
val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
)
try {
modFun.invoke()
metadataWriter.write(metadata, metadataOutputStream)
writeMetadataToCache()
} catch (e: IOException) {
Log.w(TAG, "Error writing metadata to storage", e)
// revert metadata and do not write it to cache
metadata = oldMetadata
throw IOException(e)
}
mLastBackupTime.postValue(metadata.time)
}
/**
* Returns the current backup token.
*
* If the token is 0L, it is not yet initialized and must not be used for anything.
*/
@Synchronized
@Deprecated(
"Responsibility for current token moved to SettingsManager",
ReplaceWith("settingsManager.getToken()")
)
fun getBackupToken(): Long = metadata.token
/**
* Returns the last backup time in unix epoch milli seconds.
*
* Note that this might be a blocking I/O call.
*/
@Synchronized
fun getLastBackupTime(): Long = mLastBackupTime.value ?: metadata.time
private val mLastBackupTime = MutableLiveData<Long>()
internal val lastBackupTime: LiveData<Long> = mLastBackupTime.distinctUntilChanged()
internal val salt: String
@Synchronized get() = metadata.salt
internal val requiresInit: Boolean
@Synchronized get() = metadata == uninitializedMetadata || metadata.version < VERSION
@Synchronized
fun getPackageMetadata(packageName: String): PackageMetadata? {
return metadata.packageMetadataMap[packageName]?.copy()
}
@Synchronized
fun getPackagesBackupSize(): Long {
return metadata.packageMetadataMap.values.sumOf { it.size ?: 0L }
}
@Synchronized @Synchronized
@VisibleForTesting @VisibleForTesting
private fun getMetadataFromCache(): BackupMetadata? { private fun getMetadataFromCache(): BackupMetadata? {
@ -181,4 +347,12 @@ internal class MetadataManager(
} }
} }
private fun getUserName(): String? {
val perm = "android.permission.QUERY_USERS"
return if (context.checkSelfPermission(perm) == PERMISSION_GRANTED) {
val userManager = context.getSystemService(UserManager::class.java) ?: return null
userManager.userName
} else null
}
} }

View file

@ -9,7 +9,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val metadataModule = module { val metadataModule = module {
single { MetadataManager(androidContext(), get(), get(), get()) } single { MetadataManager(androidContext(), get(), get(), get(), get(), get(), get()) }
single<MetadataWriter> { MetadataWriterImpl() } single<MetadataWriter> { MetadataWriterImpl(get()) }
single<MetadataReader> { MetadataReaderImpl(get()) } single<MetadataReader> { MetadataReaderImpl(get()) }
} }

View file

@ -56,7 +56,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
if (version == 0.toByte()) return readMetadataV0(inputStream, expectedToken) if (version == 0.toByte()) return readMetadataV0(inputStream, expectedToken)
val metadataBytes = try { val metadataBytes = try {
crypto.newDecryptingStreamV1(inputStream, getAD(version, expectedToken)).readBytes() crypto.newDecryptingStream(inputStream, getAD(version, expectedToken)).readBytes()
} catch (e: GeneralSecurityException) { } catch (e: GeneralSecurityException) {
throw DecryptionFailedException(e) throw DecryptionFailedException(e)
} }
@ -94,14 +94,14 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
val json = JSONObject(bytes.toString(Utf8)) val json = JSONObject(bytes.toString(Utf8))
// get backup metadata and check expectations // get backup metadata and check expectations
val meta = json.getJSONObject(JSON_METADATA) val meta = json.getJSONObject(JSON_METADATA)
val version = meta.optInt(JSON_METADATA_VERSION, VERSION.toInt()).toByte() val version = meta.getInt(JSON_METADATA_VERSION).toByte()
if (expectedVersion != null && version != expectedVersion) { if (expectedVersion != null && version != expectedVersion) {
throw SecurityException( throw SecurityException(
"Invalid version '${version.toInt()}' in metadata," + "Invalid version '${version.toInt()}' in metadata," +
"expected '${expectedVersion.toInt()}'." "expected '${expectedVersion.toInt()}'."
) )
} }
val token = meta.optLong(JSON_METADATA_TOKEN, 0) val token = meta.getLong(JSON_METADATA_TOKEN)
if (expectedToken != null && token != expectedToken) throw SecurityException( if (expectedToken != null && token != expectedToken) throw SecurityException(
"Invalid token '$token' in metadata, expected '$expectedToken'." "Invalid token '$token' in metadata, expected '$expectedToken'."
) )
@ -157,11 +157,11 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
return BackupMetadata( return BackupMetadata(
version = version, version = version,
token = token, token = token,
salt = if (version == 0.toByte()) "" else meta.optString(JSON_METADATA_SALT, ""), salt = if (version == 0.toByte()) "" else meta.getString(JSON_METADATA_SALT),
time = meta.optLong(JSON_METADATA_TIME, -1), time = meta.getLong(JSON_METADATA_TIME),
androidVersion = meta.optInt(JSON_METADATA_SDK_INT, 0), androidVersion = meta.getInt(JSON_METADATA_SDK_INT),
androidIncremental = meta.optString(JSON_METADATA_INCREMENTAL), androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL),
deviceName = meta.optString(JSON_METADATA_NAME), deviceName = meta.getString(JSON_METADATA_NAME),
d2dBackup = meta.optBoolean(JSON_METADATA_D2D_BACKUP, false), d2dBackup = meta.optBoolean(JSON_METADATA_D2D_BACKUP, false),
packageMetadataMap = packageMetadataMap, packageMetadataMap = packageMetadataMap,
) )

View file

@ -6,18 +6,42 @@
package com.stevesoltys.seedvault.metadata package com.stevesoltys.seedvault.metadata
import com.stevesoltys.seedvault.Utf8 import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.IOException
import java.io.OutputStream
interface MetadataWriter { interface MetadataWriter {
@Throws(IOException::class)
fun write(metadata: BackupMetadata, outputStream: OutputStream)
fun encode(metadata: BackupMetadata): ByteArray fun encode(metadata: BackupMetadata): ByteArray
} }
internal class MetadataWriterImpl : MetadataWriter { internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
@Throws(IOException::class)
override fun write(metadata: BackupMetadata, outputStream: OutputStream) {
outputStream.write(ByteArray(1).apply { this[0] = metadata.version })
crypto.newEncryptingStream(outputStream, getAD(metadata.version, metadata.token)).use {
it.write(encode(metadata))
}
}
override fun encode(metadata: BackupMetadata): ByteArray { override fun encode(metadata: BackupMetadata): ByteArray {
val json = JSONObject().apply { val json = JSONObject().apply {
put(JSON_METADATA, JSONObject()) put(JSON_METADATA, JSONObject().apply {
put(JSON_METADATA_VERSION, metadata.version.toInt())
put(JSON_METADATA_TOKEN, metadata.token)
put(JSON_METADATA_SALT, metadata.salt)
put(JSON_METADATA_TIME, metadata.time)
put(JSON_METADATA_SDK_INT, metadata.androidVersion)
put(JSON_METADATA_INCREMENTAL, metadata.androidIncremental)
put(JSON_METADATA_NAME, metadata.deviceName)
put(JSON_METADATA_D2D_BACKUP, metadata.d2dBackup)
})
} }
for ((packageName, packageMetadata) in metadata.packageMetadataMap) { for ((packageName, packageMetadata) in metadata.packageMetadataMap) {
json.put(packageName, JSONObject().apply { json.put(packageName, JSONObject().apply {
@ -33,8 +57,31 @@ internal class MetadataWriterImpl : MetadataWriter {
if (packageMetadata.size != null) { if (packageMetadata.size != null) {
put(JSON_PACKAGE_SIZE, packageMetadata.size) put(JSON_PACKAGE_SIZE, packageMetadata.size)
} }
if (packageMetadata.name != null) {
put(JSON_PACKAGE_APP_NAME, packageMetadata.name)
}
if (packageMetadata.system) {
put(JSON_PACKAGE_SYSTEM, true)
}
if (packageMetadata.isLaunchableSystemApp) {
put(JSON_PACKAGE_SYSTEM_LAUNCHER, true)
}
packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) }
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }
packageMetadata.splits?.let { splits ->
put(JSON_PACKAGE_SPLITS, JSONArray().apply {
for (split in splits) put(JSONObject().apply {
put(JSON_PACKAGE_SPLIT_NAME, split.name)
if (split.size != null) put(JSON_PACKAGE_SIZE, split.size)
put(JSON_PACKAGE_SHA256, split.sha256)
})
})
}
packageMetadata.sha256?.let { put(JSON_PACKAGE_SHA256, it) }
packageMetadata.signatures?.let { put(JSON_PACKAGE_SIGNATURES, JSONArray(it)) }
}) })
} }
return json.toString().toByteArray(Utf8) return json.toString().toByteArray(Utf8)
} }
} }

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend package com.stevesoltys.seedvault.plugins
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import java.io.IOException import java.io.IOException

View file

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

View file

@ -3,74 +3,80 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend package com.stevesoltys.seedvault.plugins
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.getStorageContext import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.repo.BlobCache import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.SafFactory
import com.stevesoltys.seedvault.plugins.webdav.WebDavFactory
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.StoragePluginType import com.stevesoltys.seedvault.settings.StoragePluginType
import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.BackendFactory
import org.calyxos.seedvault.core.backends.BackendProperties
import org.calyxos.seedvault.core.backends.saf.SafBackend
class BackendManager( class StoragePluginManager(
private val context: Context, private val context: Context,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val blobCache: BlobCache, safFactory: SafFactory,
backendFactory: BackendFactory, webDavFactory: WebDavFactory,
) { ) {
@Volatile private var mAppPlugin: StoragePlugin<*>?
private var mBackend: Backend? private var mFilesPlugin: org.calyxos.backup.storage.api.StoragePlugin?
private var mStorageProperties: StorageProperties<*>?
@Volatile val appPlugin: StoragePlugin<*>
private var mBackendProperties: BackendProperties<*>?
val backend: Backend
@Synchronized @Synchronized
get() { get() {
return mBackend ?: error("App plugin was loaded, but still null") return mAppPlugin ?: error("App plugin was loaded, but still null")
} }
val backendProperties: BackendProperties<*>? val filesPlugin: org.calyxos.backup.storage.api.StoragePlugin
@Synchronized @Synchronized
get() { get() {
return mBackendProperties return mFilesPlugin ?: error("Files plugin was loaded, but still null")
} }
val isOnRemovableDrive: Boolean get() = backendProperties?.isUsb == true
val requiresNetwork: Boolean get() = backendProperties?.requiresNetwork == true val storageProperties: StorageProperties<*>?
@Synchronized
get() {
return mStorageProperties
}
val isOnRemovableDrive: Boolean get() = storageProperties?.isUsb == true
init { init {
when (settingsManager.storagePluginType) { when (settingsManager.storagePluginType) {
StoragePluginType.SAF -> { StoragePluginType.SAF -> {
val safConfig = settingsManager.getSafProperties() ?: error("No SAF storage saved") val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage saved")
mBackend = backendFactory.createSafBackend(safConfig) val documentsStorage = DocumentsStorage(context, settingsManager, safStorage)
mBackendProperties = safConfig mAppPlugin = safFactory.createAppStoragePlugin(safStorage, documentsStorage)
mFilesPlugin = safFactory.createFilesStoragePlugin(safStorage, documentsStorage)
mStorageProperties = safStorage
} }
StoragePluginType.WEB_DAV -> { StoragePluginType.WEB_DAV -> {
val webDavProperties = val webDavProperties =
settingsManager.webDavProperties ?: error("No WebDAV config saved") settingsManager.webDavProperties ?: error("No WebDAV config saved")
mBackend = backendFactory.createWebDavBackend(webDavProperties.config) mAppPlugin = webDavFactory.createAppStoragePlugin(webDavProperties.config)
mBackendProperties = webDavProperties mFilesPlugin = webDavFactory.createFilesStoragePlugin(webDavProperties.config)
mStorageProperties = webDavProperties
} }
null -> { null -> {
mBackend = null mAppPlugin = null
mBackendProperties = null mFilesPlugin = null
mStorageProperties = null
} }
} }
} }
fun isValidAppPluginSet(): Boolean { fun isValidAppPluginSet(): Boolean {
if (mBackend == null) return false if (mAppPlugin == null || mFilesPlugin == null) return false
if (mBackend is SafBackend) { if (mAppPlugin is DocumentsProviderStoragePlugin) {
val storage = settingsManager.getSafProperties() ?: return false val storage = settingsManager.getSafStorage() ?: return false
if (storage.isUsb) return true if (storage.isUsb) return true
return permitDiskReads { return permitDiskReads {
storage.getDocumentFile(context).isDirectory storage.getDocumentFile(context).isDirectory
@ -80,22 +86,20 @@ class BackendManager(
} }
/** /**
* Changes the storage plugins and current [BackendProperties]. * Changes the storage plugins and current [StorageProperties].
* *
* IMPORTANT: Do no call this while current plugins are being used, * IMPORTANT: Do no call this while current plugins are being used,
* e.g. while backup/restore operation is still running. * e.g. while backup/restore operation is still running.
*/ */
@WorkerThread
@Synchronized
fun <T> changePlugins( fun <T> changePlugins(
backend: Backend, storageProperties: StorageProperties<T>,
storageProperties: BackendProperties<T>, appPlugin: StoragePlugin<T>,
filesPlugin: org.calyxos.backup.storage.api.StoragePlugin,
) { ) {
settingsManager.setStorageBackend(backend) settingsManager.setStoragePlugin(appPlugin)
mBackend = backend mStorageProperties = storageProperties
mBackendProperties = storageProperties mAppPlugin = appPlugin
blobCache.clearLocalCache() mFilesPlugin = filesPlugin
// TODO not critical, but nice to have: clear also local snapshot cache
} }
/** /**
@ -108,7 +112,7 @@ class BackendManager(
*/ */
@WorkerThread @WorkerThread
fun canDoBackupNow(): Boolean { fun canDoBackupNow(): Boolean {
val storage = backendProperties ?: return false val storage = storageProperties ?: return false
return !isOnUnavailableUsb() && return !isOnUnavailableUsb() &&
!storage.isUnavailableNetwork(context, settingsManager.useMeteredNetwork) !storage.isUnavailableNetwork(context, settingsManager.useMeteredNetwork)
} }
@ -123,7 +127,7 @@ class BackendManager(
*/ */
@WorkerThread @WorkerThread
fun isOnUnavailableUsb(): Boolean { fun isOnUnavailableUsb(): Boolean {
val storage = backendProperties ?: return false val storage = storageProperties ?: return false
val systemContext = context.getStorageContext { storage.isUsb } val systemContext = context.getStorageContext { storage.isUsb }
return storage.isUnavailableUsb(systemContext) return storage.isUnavailableUsb(systemContext)
} }
@ -134,7 +138,7 @@ class BackendManager(
@WorkerThread @WorkerThread
suspend fun getFreeSpace(): Long? { suspend fun getFreeSpace(): Long? {
return try { return try {
backend.getFreeSpace() appPlugin.getFreeSpace()
} catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm } catch (e: Throwable) { // NoClassDefFound isn't an [Exception], can get thrown by dav4jvm
Log.e("StoragePluginManager", "Error getting free space: ", e) Log.e("StoragePluginManager", "Error getting free space: ", e)
null null

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package org.calyxos.seedvault.core.backends package com.stevesoltys.seedvault.plugins
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
@ -12,20 +12,20 @@ import androidx.annotation.WorkerThread
import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.exception.HttpException
import java.io.IOException import java.io.IOException
public abstract class BackendProperties<T> { abstract class StorageProperties<T> {
public abstract val config: T abstract val config: T
public abstract val name: String abstract val name: String
public abstract val isUsb: Boolean abstract val isUsb: Boolean
public abstract val requiresNetwork: Boolean abstract val requiresNetwork: Boolean
@WorkerThread @WorkerThread
public abstract fun isUnavailableUsb(context: Context): Boolean abstract fun isUnavailableUsb(context: Context): Boolean
/** /**
* Returns true if this is storage that requires network access, * Returns true if this is storage that requires network access,
* but it isn't available right now. * but it isn't available right now.
*/ */
public fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean { fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean {
return requiresNetwork && !hasUnmeteredInternet(context, allowMetered) return requiresNetwork && !hasUnmeteredInternet(context, allowMetered)
} }
@ -37,7 +37,7 @@ public abstract class BackendProperties<T> {
} }
} }
public fun Exception.isOutOfSpace(): Boolean { fun Exception.isOutOfSpace(): Boolean {
return when (this) { return when (this) {
is IOException -> message?.contains("No space left on device") == true || is IOException -> message?.contains("No space left on device") == true ||
(cause as? HttpException)?.code == 507 (cause as? HttpException)?.code == 507

View file

@ -3,13 +3,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream

View file

@ -3,14 +3,15 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend.saf package com.stevesoltys.seedvault.plugins.saf
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val storagePluginModuleSaf = module { val storagePluginModuleSaf = module {
single { SafFactory(androidContext(), get(), get()) }
single { SafHandler(androidContext(), get(), get(), get()) } single { SafHandler(androidContext(), get(), get(), get()) }
@Suppress("Deprecation") @Suppress("Deprecation")
@ -18,9 +19,8 @@ val storagePluginModuleSaf = module {
DocumentsProviderLegacyPlugin( DocumentsProviderLegacyPlugin(
context = androidContext(), context = androidContext(),
storageGetter = { storageGetter = {
val safProperties = get<SettingsManager>().getSafProperties() val safStorage = get<SettingsManager>().getSafStorage() ?: error("No SAF storage")
?: error("No SAF storage") DocumentsStorage(androidContext(), get(), safStorage)
DocumentsStorage(androidContext(), safProperties)
}, },
) )
} }

View file

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

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
@ -20,28 +20,33 @@ import android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.getStorageContext import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.settings.SettingsManager
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT
import org.calyxos.seedvault.core.backends.saf.SafProperties
import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream
import kotlin.coroutines.resume import kotlin.coroutines.resume
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
@Deprecated("") @Deprecated("")
const val DIRECTORY_FULL_BACKUP = "full" const val DIRECTORY_FULL_BACKUP = "full"
@Deprecated("") @Deprecated("")
const val DIRECTORY_KEY_VALUE_BACKUP = "kv" const val DIRECTORY_KEY_VALUE_BACKUP = "kv"
const val FILE_BACKUP_METADATA = ".backup.metadata"
const val FILE_NO_MEDIA = ".nomedia"
const val MIME_TYPE = "application/octet-stream"
private val TAG = DocumentsStorage::class.java.simpleName private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage( internal class DocumentsStorage(
private val appContext: Context, private val appContext: Context,
internal val safStorage: SafProperties, private val settingsManager: SettingsManager,
internal val safStorage: SafStorage,
) { ) {
/** /**
@ -50,12 +55,16 @@ internal class DocumentsStorage(
private val context: Context get() = appContext.getStorageContext { safStorage.isUsb } private val context: Context get() = appContext.getStorageContext { safStorage.isUsb }
private val contentResolver: ContentResolver get() = context.contentResolver private val contentResolver: ContentResolver get() = context.contentResolver
private var rootBackupDir: DocumentFile? = null internal var rootBackupDir: DocumentFile? = null
get() = runBlocking { get() = runBlocking {
if (field == null) { if (field == null) {
val parent = safStorage.getDocumentFile(context) val parent = safStorage.getDocumentFile(context)
field = try { field = try {
parent.createOrGetDirectory(context, DIRECTORY_ROOT) parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
// create .nomedia file to prevent Android's MediaScanner
// from trying to index the backup
createOrGetFile(context, FILE_NO_MEDIA)
}
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e) Log.e(TAG, "Error creating root backup dir.", e)
null null
@ -64,8 +73,41 @@ internal class DocumentsStorage(
field field
} }
private var currentToken: Long? = null
get() {
if (field == null) field = settingsManager.getToken()
return field
}
var currentSetDir: DocumentFile? = null
get() = runBlocking {
if (field == null) {
if (currentToken == 0L) return@runBlocking null
field = try {
rootBackupDir?.createOrGetDirectory(context, currentToken.toString())
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
field
}
private set
/**
* Resets this storage abstraction, forcing it to re-fetch cached values on next access.
*/
fun reset(newToken: Long?) {
currentToken = newToken
rootBackupDir = null
currentSetDir = null
}
fun getAuthority(): String? = safStorage.uri.authority
@Throws(IOException::class) @Throws(IOException::class)
suspend fun getSetDir(token: Long): DocumentFile? { suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentSetDir
return rootBackupDir?.findFileBlocking(context, token.toString()) return rootBackupDir?.findFileBlocking(context, token.toString())
} }
@ -93,6 +135,43 @@ internal class DocumentsStorage(
} }
} }
@Throws(IOException::class)
fun getOutputStream(file: DocumentFile): OutputStream {
return try {
contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException()
} catch (e: Exception) {
// SAF can throw all sorts of exceptions, so wrap it in IOException
throw IOException(e)
}
}
}
/**
* Checks if a file exists and if not, creates it.
*
* If we were trying to create it right away, some providers create "filename (1)".
*/
@Throws(IOException::class)
internal suspend fun DocumentFile.createOrGetFile(
context: Context,
name: String,
mimeType: String = MIME_TYPE,
): DocumentFile {
return try {
findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
if (this.name != name) {
throw IOException("File named ${this.name}, but should be $name")
}
} ?: throw IOException("could not find nor create")
} catch (e: Exception) {
// SAF can throw all sorts of exceptions, so wrap it in IOException.
// E.g. IllegalArgumentException can be thrown by FileSystemProvider#isChildDocument()
// when flash drive is not plugged-in:
// http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135
if (e is IOException) throw e
else throw IOException(e)
}
} }
/** /**
@ -107,6 +186,11 @@ suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): D
} ?: throw IOException() } ?: throw IOException()
} }
@Throws(IOException::class)
suspend fun DocumentFile.deleteContents(context: Context) {
for (file in listFilesBlocking(context)) file.delete()
}
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) { fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
if (name != packageInfo.packageName) { if (name != packageInfo.packageName) {
throw AssertionError("Expected ${packageInfo.packageName}, but got $name") throw AssertionError("Expected ${packageInfo.packageName}, but got $name")
@ -140,6 +224,26 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile>
return result return result
} }
/**
* An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent.
*
* All other public ways to get a TreeDocumentFile only work from [Uri]s
* (e.g. [DocumentFile.fromTreeUri]) and always set parent to null.
*
* We have a test for this method to ensure CI will alert us when this reflection breaks.
* Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails.
*/
@VisibleForTesting
internal fun getTreeDocumentFile(parent: DocumentFile, context: Context, uri: Uri): DocumentFile {
@SuppressWarnings("MagicNumber")
val constructor = parent.javaClass.declaredConstructors.find {
it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3
}
check(constructor != null) { "Could not find constructor for TreeDocumentFile" }
constructor.isAccessible = true
return constructor.newInstance(parent, context, uri) as DocumentFile
}
/** /**
* Same as [DocumentFile.findFile] only that it re-queries when the first result was stale. * Same as [DocumentFile.findFile] only that it re-queries when the first result was stale.
* *
@ -181,7 +285,7 @@ suspend fun DocumentFile.findFileBlocking(context: Context, displayName: String)
@Throws(IOException::class, TimeoutCancellationException::class) @Throws(IOException::class, TimeoutCancellationException::class)
internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) = internal suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?) =
withTimeout(timeout) { withTimeout(timeout) {
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine<Cursor> { cont ->
val cursor = query() ?: throw IOException() val cursor = query() ?: throw IOException()
cont.invokeOnCancellation { cursor.close() } cont.invokeOnCancellation { cursor.close() }
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false) val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)

View file

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

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context import android.content.Context
import android.content.Context.USB_SERVICE import android.content.Context.USB_SERVICE
@ -14,41 +14,33 @@ import android.net.Uri
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.isMassStorage import com.stevesoltys.seedvault.isMassStorage
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.FlashDrive import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.storage.StorageOption import com.stevesoltys.seedvault.ui.storage.StorageOption
import org.calyxos.seedvault.core.backends.BackendFactory
import org.calyxos.seedvault.core.backends.saf.SafProperties
import java.io.IOException import java.io.IOException
private const val TAG = "SafHandler" private const val TAG = "SafHandler"
internal class SafHandler( internal class SafHandler(
private val context: Context, private val context: Context,
private val backendFactory: BackendFactory, private val safFactory: SafFactory,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val backendManager: BackendManager, private val storagePluginManager: StoragePluginManager,
) { ) {
fun onConfigReceived(uri: Uri, safOption: StorageOption.SafOption): SafProperties { fun onConfigReceived(uri: Uri, safOption: StorageOption.SafOption): SafStorage {
// persist permission to access backup folder across reboots // persist permission to access backup folder across reboots
val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags) context.contentResolver.takePersistableUriPermission(uri, takeFlags)
return SafProperties( val name = if (safOption.isInternal()) {
config = uri, "${safOption.title} (${context.getString(R.string.settings_backup_location_internal)})"
name = if (safOption.isInternal()) { } else {
val brackets = context.getString(R.string.settings_backup_location_internal) safOption.title
"${safOption.title} ($brackets)" }
} else { return SafStorage(uri, name, safOption.isUsb, safOption.requiresNetwork, safOption.rootId)
safOption.title
},
isUsb = safOption.isUsb,
requiresNetwork = safOption.requiresNetwork,
rootId = safOption.rootId,
)
} }
/** /**
@ -57,15 +49,17 @@ internal class SafHandler(
*/ */
@WorkerThread @WorkerThread
@Throws(IOException::class) @Throws(IOException::class)
suspend fun hasAppBackup(safProperties: SafProperties): Boolean { suspend fun hasAppBackup(safStorage: SafStorage): Boolean {
val backend = backendFactory.createSafBackend(safProperties) val storage = DocumentsStorage(context, settingsManager, safStorage)
return backend.getAvailableBackupFileHandles().isNotEmpty() val appPlugin = safFactory.createAppStoragePlugin(safStorage, storage)
val backups = appPlugin.getAvailableBackups()
return backups != null && backups.iterator().hasNext()
} }
fun save(safProperties: SafProperties) { fun save(safStorage: SafStorage) {
settingsManager.setSafProperties(safProperties) settingsManager.setSafStorage(safStorage)
if (safProperties.isUsb) { if (safStorage.isUsb) {
Log.d(TAG, "Selected storage is a removable USB device.") Log.d(TAG, "Selected storage is a removable USB device.")
val wasSaved = saveUsbDevice() val wasSaved = saveUsbDevice()
// reset stored flash drive, if we did not update it // reset stored flash drive, if we did not update it
@ -73,7 +67,7 @@ internal class SafHandler(
} else { } else {
settingsManager.setFlashDrive(null) settingsManager.setFlashDrive(null)
} }
Log.d(TAG, "New storage location saved: ${safProperties.uri}") Log.d(TAG, "New storage location saved: ${safStorage.uri}")
} }
private fun saveUsbDevice(): Boolean { private fun saveUsbDevice(): Boolean {
@ -90,11 +84,12 @@ internal class SafHandler(
return false return false
} }
@WorkerThread fun setPlugin(safStorage: SafStorage) {
fun setPlugin(safProperties: SafProperties) { val storage = DocumentsStorage(context, settingsManager, safStorage)
backendManager.changePlugins( storagePluginManager.changePlugins(
backend = backendFactory.createSafBackend(safProperties), storageProperties = safStorage,
storageProperties = safProperties, appPlugin = safFactory.createAppStoragePlugin(safStorage, storage),
filesPlugin = safFactory.createFilesStoragePlugin(safStorage, storage),
) )
} }
} }

View file

@ -3,16 +3,16 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package org.calyxos.seedvault.core.backends.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import org.calyxos.seedvault.core.backends.BackendProperties import com.stevesoltys.seedvault.plugins.StorageProperties
public data class SafProperties( data class SafStorage(
override val config: Uri, override val config: Uri,
override val name: String, override val name: String,
override val isUsb: Boolean, override val isUsb: Boolean,
@ -22,13 +22,12 @@ public data class SafProperties(
* This is only nullable for historic reasons, because we didn't always store it. * This is only nullable for historic reasons, because we didn't always store it.
*/ */
val rootId: String?, val rootId: String?,
) : BackendProperties<Uri>() { ) : StorageProperties<Uri>() {
public val uri: Uri = config val uri: Uri = config
public fun getDocumentFile(context: Context): DocumentFile = fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, config)
DocumentFile.fromTreeUri(context, config) ?: throw AssertionError("Should only happen on API < 21.")
?: throw AssertionError("Should only happen on API < 21.")
/** /**
* Returns true if this is USB storage that is not available, false otherwise. * Returns true if this is USB storage that is not available, false otherwise.

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -14,7 +14,7 @@ import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.saf.StorageRootResolver.getIcon import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver.getIcon
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5 import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend.saf package com.stevesoltys.seedvault.plugins.saf
import android.Manifest.permission.MANAGE_DOCUMENTS import android.Manifest.permission.MANAGE_DOCUMENTS
import android.content.Context import android.content.Context
@ -139,6 +139,17 @@ internal object StorageRootResolver {
return if (index != -1) getInt(index) else 0 return if (index != -1) getInt(index) else 0
} }
private fun Cursor.getLong(columnName: String): Long? {
val index = getColumnIndex(columnName)
if (index == -1) return null
val value = getString(index) ?: return null
return try {
java.lang.Long.parseLong(value)
} catch (e: NumberFormatException) {
null
}
}
fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? { fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
return getPackageIcon(context, authority, icon) ?: when { return getPackageIcon(context, authority, icon) ?: when {
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> { authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {

View file

@ -3,9 +3,9 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package org.calyxos.seedvault.core.backends.webdav package com.stevesoltys.seedvault.plugins.webdav
public data class WebDavConfig( data class WebDavConfig(
val url: String, val url: String,
val username: String, val username: String,
val password: String, val password: String,

View file

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

View file

@ -3,20 +3,17 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend.webdav package com.stevesoltys.seedvault.plugins.webdav
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import org.calyxos.seedvault.core.backends.Backend import okhttp3.HttpUrl.Companion.toHttpUrl
import org.calyxos.seedvault.core.backends.BackendFactory
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
import java.io.IOException import java.io.IOException
internal sealed interface WebDavConfigState { internal sealed interface WebDavConfigState {
@ -24,7 +21,7 @@ internal sealed interface WebDavConfigState {
object Checking : WebDavConfigState object Checking : WebDavConfigState
class Success( class Success(
val properties: WebDavProperties, val properties: WebDavProperties,
val backend: Backend, val plugin: WebDavStoragePlugin,
) : WebDavConfigState ) : WebDavConfigState
class Error(val e: Exception?) : WebDavConfigState class Error(val e: Exception?) : WebDavConfigState
@ -34,14 +31,14 @@ private val TAG = WebDavHandler::class.java.simpleName
internal class WebDavHandler( internal class WebDavHandler(
private val context: Context, private val context: Context,
private val backendFactory: BackendFactory, private val webDavFactory: WebDavFactory,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val backendManager: BackendManager, private val storagePluginManager: StoragePluginManager,
) { ) {
companion object { companion object {
fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties { fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties {
val host = config.url.removePrefix("https://") val host = config.url.toHttpUrl().host
return WebDavProperties( return WebDavProperties(
config = config, config = config,
name = context.getString(R.string.storage_webdav_name, host), name = context.getString(R.string.storage_webdav_name, host),
@ -54,11 +51,11 @@ internal class WebDavHandler(
suspend fun onConfigReceived(config: WebDavConfig) { suspend fun onConfigReceived(config: WebDavConfig) {
mConfigState.value = WebDavConfigState.Checking mConfigState.value = WebDavConfigState.Checking
val backend = backendFactory.createWebDavBackend(config) val plugin = webDavFactory.createAppStoragePlugin(config) as WebDavStoragePlugin
try { try {
if (backend.test()) { if (plugin.test()) {
val properties = createWebDavProperties(context, config) val properties = createWebDavProperties(context, config)
mConfigState.value = WebDavConfigState.Success(properties, backend) mConfigState.value = WebDavConfigState.Success(properties, plugin)
} else { } else {
mConfigState.value = WebDavConfigState.Error(null) mConfigState.value = WebDavConfigState.Error(null)
} }
@ -78,19 +75,20 @@ internal class WebDavHandler(
*/ */
@WorkerThread @WorkerThread
@Throws(IOException::class) @Throws(IOException::class)
suspend fun hasAppBackup(backend: Backend): Boolean { suspend fun hasAppBackup(appPlugin: WebDavStoragePlugin): Boolean {
return backend.getAvailableBackupFileHandles().isNotEmpty() val backups = appPlugin.getAvailableBackups()
return backups != null && backups.iterator().hasNext()
} }
fun save(properties: WebDavProperties) { fun save(properties: WebDavProperties) {
settingsManager.saveWebDavConfig(properties.config) settingsManager.saveWebDavConfig(properties.config)
} }
@WorkerThread fun setPlugin(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
fun setPlugin(properties: WebDavProperties, backend: Backend) { storagePluginManager.changePlugins(
backendManager.changePlugins(
backend = backend,
storageProperties = properties, storageProperties = properties,
appPlugin = plugin,
filesPlugin = webDavFactory.createFilesStoragePlugin(properties.config),
) )
} }

View file

@ -3,11 +3,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package com.stevesoltys.seedvault.backend.webdav package com.stevesoltys.seedvault.plugins.webdav
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val storagePluginModuleWebDav = module { val storagePluginModuleWebDav = module {
single { WebDavFactory(androidContext(), get()) }
single { WebDavHandler(androidContext(), get(), get(), get()) } single { WebDavHandler(androidContext(), get(), get(), get()) }
} }

View file

@ -3,15 +3,15 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
package org.calyxos.seedvault.core.backends.webdav package com.stevesoltys.seedvault.plugins.webdav
import android.content.Context import android.content.Context
import org.calyxos.seedvault.core.backends.BackendProperties import com.stevesoltys.seedvault.plugins.StorageProperties
public data class WebDavProperties( data class WebDavProperties(
override val config: WebDavConfig, override val config: WebDavConfig,
override val name: String, override val name: String,
) : BackendProperties<WebDavConfig>() { ) : StorageProperties<WebDavConfig>() {
override val isUsb: Boolean = false override val isUsb: Boolean = false
override val requiresNetwork: Boolean = true override val requiresNetwork: Boolean = true
override fun isUnavailableUsb(context: Context): Boolean = false override fun isUnavailableUsb(context: Context): Boolean = false

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,12 +23,12 @@ import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.install.isInstalled import com.stevesoltys.seedvault.restore.install.isInstalled
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.AppBackupState import com.stevesoltys.seedvault.ui.AppBackupState
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
@ -54,8 +54,9 @@ internal data class AppRestoreResult(
internal class AppDataRestoreManager( internal class AppDataRestoreManager(
private val context: Context, private val context: Context,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val settingsManager: SettingsManager,
private val restoreCoordinator: RestoreCoordinator, private val restoreCoordinator: RestoreCoordinator,
private val backendManager: BackendManager, private val storagePluginManager: StoragePluginManager,
) { ) {
private var session: IRestoreSession? = null private var session: IRestoreSession? = null
@ -83,6 +84,12 @@ internal class AppDataRestoreManager(
Log.d(TAG, "Starting new restore session to restore backup $token") Log.d(TAG, "Starting new restore session to restore backup $token")
// if we had no token before (i.e. restore from setup wizard),
// use the token of the current restore set from now on
if (settingsManager.getToken() == null) {
settingsManager.setNewToken(token)
}
// start a new restore session // start a new restore session
val session = try { val session = try {
getOrStartSession() getOrStartSession()
@ -94,7 +101,7 @@ internal class AppDataRestoreManager(
return return
} }
val providerPackageName = backendManager.backend.providerPackageName val providerPackageName = storagePluginManager.appPlugin.providerPackageName
val observer = RestoreObserver( val observer = RestoreObserver(
restoreCoordinator = restoreCoordinator, restoreCoordinator = restoreCoordinator,
restorableBackup = restorableBackup, restorableBackup = restorableBackup,
@ -210,7 +217,7 @@ internal class AppDataRestoreManager(
context.stopService(foregroundServiceIntent) context.stopService(foregroundServiceIntent)
} }
private fun closeSession() { fun closeSession() {
session?.endRestoreSession() session?.endRestoreSession()
session = null session = null
} }
@ -256,20 +263,20 @@ internal class AppDataRestoreManager(
/** /**
* Restore the next chunk of packages. * Restore the next chunk of packages.
* *
* We need to restore packages in chunks, otherwise [BackupTransport.startRestore] in the * We need to restore in chunks, otherwise [BackupTransport.startRestore] in the
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder transaction, * framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder
* causing the entire restoration to fail due to too many package names. * transaction, causing the entire restoration to fail.
*/ */
private fun restoreNextPackages() { private fun restoreNextPackages() {
// Make sure metadata for selected backup is cached before starting each chunk. // Make sure metadata for selected backup is cached before starting each chunk.
restoreCoordinator.beforeStartRestore(restorableBackup) val backupMetadata = restorableBackup.backupMetadata
restoreCoordinator.beforeStartRestore(backupMetadata)
val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size) val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size)
val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray() val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
packageIndex += packageChunk.size packageIndex += packageChunk.size
Log.d(TAG, "restoreNextPackages() with packageIndex=$packageIndex")
val token = restorableBackup.token val token = backupMetadata.token
val result = session.restorePackages(token, this, packageChunk, monitor) val result = session.restorePackages(token, this, packageChunk, monitor)
@Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS @Suppress("UNRESOLVED_REFERENCE") // BackupManager.SUCCESS
@ -310,7 +317,6 @@ internal class AppDataRestoreManager(
*/ */
override fun restoreFinished(result: Int) { override fun restoreFinished(result: Int) {
val chunkIndex = packageIndex / PACKAGES_PER_CHUNK val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
Log.d(TAG, "restoreFinished($result) with chunkIndex=$chunkIndex")
chunkResults[chunkIndex] = result chunkResults[chunkIndex] = result
// Restore next chunk if successful and there are more packages to restore. // Restore next chunk if successful and there are more packages to restore.
@ -319,7 +325,6 @@ internal class AppDataRestoreManager(
return return
} }
Log.d(TAG, "onRestoreComplete()")
// Restore finished, time to get the result. // Restore finished, time to get the result.
onRestoreComplete(getRestoreResult(), restorableBackup) onRestoreComplete(getRestoreResult(), restorableBackup)
closeSession() closeSession()

View file

@ -13,11 +13,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.checkbox.MaterialCheckBox import com.google.android.material.checkbox.MaterialCheckBox
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class AppSelectionFragment : Fragment() { class AppSelectionFragment : Fragment() {
private val viewModel: RestoreViewModel by activityViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = AppSelectionAdapter(lifecycleScope, this::loadIcon) { item -> private val adapter = AppSelectionAdapter(lifecycleScope, this::loadIcon) { item ->

View file

@ -12,12 +12,12 @@ import androidx.lifecycle.asLiveData
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.transport.restore.RestorableBackup import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM
import com.stevesoltys.seedvault.ui.systemData import com.stevesoltys.seedvault.ui.systemData
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
import com.stevesoltys.seedvault.worker.IconManager import com.stevesoltys.seedvault.worker.IconManager
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -25,7 +25,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import java.util.Locale import java.util.Locale
internal class SelectedAppsState( internal class SelectedAppsState(
@ -38,7 +37,7 @@ private val TAG = AppSelectionManager::class.simpleName
internal class AppSelectionManager( internal class AppSelectionManager(
private val context: Context, private val context: Context,
private val backendManager: BackendManager, private val pluginManager: StoragePluginManager,
private val iconManager: IconManager, private val iconManager: IconManager,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val workDispatcher: CoroutineDispatcher = Dispatchers.IO, private val workDispatcher: CoroutineDispatcher = Dispatchers.IO,
@ -69,41 +68,31 @@ internal class AppSelectionManager(
val name = context.getString(data.nameRes) val name = context.getString(data.nameRes)
SelectableAppItem(packageName, metadata.copy(name = name), true) SelectableAppItem(packageName, metadata.copy(name = name), true)
} }
if (restorableBackup.packageMetadataMap.isNotEmpty()) { val systemItem = SelectableAppItem(
val systemItem = SelectableAppItem( packageName = PACKAGE_NAME_SYSTEM,
packageName = PACKAGE_NAME_SYSTEM, metadata = PackageMetadata(
metadata = PackageMetadata( time = restorableBackup.packageMetadataMap.values.maxOf {
time = restorableBackup.packageMetadataMap.values.maxOf { if (it.system) it.time else -1
if (it.system) it.time else -1 },
}, size = restorableBackup.packageMetadataMap.values.sumOf {
size = restorableBackup.packageMetadataMap.values.sumOf { if (it.system) it.size ?: 0L else 0L
if (it.system) it.size ?: 0L else 0L },
}, system = true,
system = true, name = context.getString(R.string.backup_system_apps),
name = context.getString(R.string.backup_system_apps), ),
), selected = isSetupWizard,
selected = isSetupWizard, )
) items.add(0, systemItem)
items.add(0, systemItem)
}
items.addAll(0, systemDataItems) items.addAll(0, systemDataItems)
selectedApps.value = selectedApps.value =
SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false) SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false)
// download icons // download icons
coroutineScope.launch(workDispatcher) { coroutineScope.launch(workDispatcher) {
val plugin = pluginManager.appPlugin
val token = restorableBackup.token
val packagesWithIcons = try { val packagesWithIcons = try {
if (restorableBackup.version == 1.toByte()) { plugin.getInputStream(token, FILE_BACKUP_ICONS).use {
val backend = backendManager.backend iconManager.downloadIcons(restorableBackup.version, token, it)
val token = restorableBackup.token
backend.load(LegacyAppBackupFile.IconsFile(token)).use {
iconManager.downloadIconsV1(token, it)
}
} else if (restorableBackup.version >= 2) {
val repoId = restorableBackup.repoId ?: error("No repoId in v2 backup")
val snapshot = restorableBackup.snapshot ?: error("No snapshot in v2 backup")
iconManager.downloadIcons(repoId, snapshot)
} else {
emptySet()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error loading icons:", e) Log.e(TAG, "Error loading icons:", e)

View file

@ -14,11 +14,11 @@ import android.widget.Button
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import org.calyxos.backup.storage.ui.restore.FileSelectionFragment import org.calyxos.backup.storage.ui.restore.FileSelectionFragment
import org.calyxos.backup.storage.ui.restore.FilesItem import org.calyxos.backup.storage.ui.restore.FilesItem
import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
internal class FilesSelectionFragment : FileSelectionFragment() { internal class FilesSelectionFragment : FileSelectionFragment() {
override val viewModel: RestoreViewModel by activityViewModel() override val viewModel: RestoreViewModel by sharedViewModel()
private lateinit var button: Button private lateinit var button: Button
override fun onCreateView( override fun onCreateView(

View file

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

View file

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

View file

@ -8,7 +8,6 @@ package com.stevesoltys.seedvault.restore
import android.os.Bundle import android.os.Bundle
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
@ -36,7 +35,6 @@ class RestoreActivity : RequireProvisioningActivity() {
SELECT_APPS -> showFragment(AppSelectionFragment()) SELECT_APPS -> showFragment(AppSelectionFragment())
RESTORE_APPS -> showFragment(InstallProgressFragment()) RESTORE_APPS -> showFragment(InstallProgressFragment())
RESTORE_BACKUP -> showFragment(RestoreProgressFragment()) RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
RECYCLE_BACKUP -> showFragment(RecycleBackupFragment())
RESTORE_FILES -> showFragment(RestoreFilesFragment()) RESTORE_FILES -> showFragment(RestoreFilesFragment())
RESTORE_SELECT_FILES -> showFragment(FilesSelectionFragment(), true) RESTORE_SELECT_FILES -> showFragment(FilesSelectionFragment(), true)
RESTORE_FILES_STARTED -> { RESTORE_FILES_STARTED -> {

View file

@ -17,10 +17,10 @@ import androidx.fragment.app.Fragment
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import org.calyxos.backup.storage.api.SnapshotItem import org.calyxos.backup.storage.api.SnapshotItem
import org.calyxos.backup.storage.ui.restore.SnapshotFragment import org.calyxos.backup.storage.ui.restore.SnapshotFragment
import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
internal class RestoreFilesFragment : SnapshotFragment() { internal class RestoreFilesFragment : SnapshotFragment() {
override val viewModel: RestoreViewModel by activityViewModel() override val viewModel: RestoreViewModel by sharedViewModel()
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,

View file

@ -22,11 +22,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_INSTALLED
import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class RestoreProgressFragment : Fragment() { class RestoreProgressFragment : Fragment() {
private val viewModel: RestoreViewModel by activityViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon) private val adapter = RestoreProgressAdapter(lifecycleScope, this::loadIcon)

View file

@ -39,8 +39,8 @@ class RestoreService : Service() {
override fun onDestroy() { override fun onDestroy() {
Log.i(TAG, "onDestroy") Log.i(TAG, "onDestroy")
nm.cancelRestoreNotification()
super.onDestroy() super.onDestroy()
nm.cancelRestoreNotification()
} }
} }

View file

@ -6,25 +6,22 @@
package com.stevesoltys.seedvault.restore package com.stevesoltys.seedvault.restore
import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE import android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE
import android.text.format.DateUtils.MINUTE_IN_MILLIS import android.text.format.DateUtils.HOUR_IN_MILLIS
import android.text.format.DateUtils.getRelativeTimeSpanString import android.text.format.DateUtils.getRelativeTimeSpanString
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.GONE import android.view.View.GONE
import android.view.View.VISIBLE import android.view.View.VISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.google.android.material.color.MaterialColors
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder import com.stevesoltys.seedvault.restore.RestoreSetAdapter.RestoreSetViewHolder
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
internal class RestoreSetAdapter( internal class RestoreSetAdapter(
private val listener: RestorableBackupClickListener?, private val listener: RestorableBackupClickListener,
private val items: List<RestorableBackup>, private val items: List<RestorableBackup>,
) : Adapter<RestoreSetViewHolder>() { ) : Adapter<RestoreSetViewHolder>() {
@ -42,57 +39,33 @@ internal class RestoreSetAdapter(
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) { inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
private val imageView = v.requireViewById<ImageView>(R.id.imageView)
private val titleView = v.requireViewById<TextView>(R.id.titleView) private val titleView = v.requireViewById<TextView>(R.id.titleView)
private val appView = v.requireViewById<TextView>(R.id.appView) private val subtitleView = v.requireViewById<TextView>(R.id.subtitleView)
private val apkView = v.requireViewById<TextView>(R.id.apkView) private val sizeView = v.requireViewById<TextView>(R.id.sizeView)
private val timeView = v.requireViewById<TextView>(R.id.timeView)
internal fun bind(item: RestorableBackup) { internal fun bind(item: RestorableBackup) {
if (listener != null) { v.setOnClickListener { listener.onRestorableBackupClicked(item) }
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
}
if (item.canBeRestored) {
imageView.setImageResource(R.drawable.ic_phone_android)
} else {
imageView.setImageResource(R.drawable.ic_error_red)
}
titleView.text = item.name titleView.text = item.name
appView.text = if (item.sizeAppData > 0) { val lastBackup = getRelativeTime(item.time)
v.context.getString( val setup = getRelativeTime(item.token)
R.string.restore_restore_set_apps, subtitleView.text =
item.numAppData, v.context.getString(R.string.restore_restore_set_times, lastBackup, setup)
formatShortFileSize(v.context, item.sizeAppData), val size = item.size
if (size == null) {
sizeView.visibility = GONE
} else {
sizeView.text = v.context.getString(
R.string.restore_restore_set_size,
Formatter.formatShortFileSize(v.context, size),
) )
} else { sizeView.visibility = VISIBLE
v.context.getString(R.string.restore_restore_set_apps_no_size, item.numAppData)
} }
appView.visibility = if (item.numAppData > 0) VISIBLE else GONE
apkView.text = if (!item.canBeRestored) {
v.context.getString(R.string.restore_restore_set_can_not_get_restored)
} else if (item.sizeApks > 0) {
v.context.getString(
R.string.restore_restore_set_apks,
item.numApks,
formatShortFileSize(v.context, item.sizeApks),
)
} else {
v.context.getString(R.string.restore_restore_set_apks_no_size, item.numApks)
}
apkView.visibility = if (item.numApks > 0 || !item.canBeRestored) VISIBLE else GONE
val apkTextColor = if (item.canBeRestored) {
appView.currentTextColor
} else {
MaterialColors.getColor(apkView, R.attr.colorError)
}
apkView.setTextColor(apkTextColor)
timeView.text = getRelativeTime(item.time)
} }
private fun getRelativeTime(time: Long): CharSequence { private fun getRelativeTime(time: Long): CharSequence {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
return getRelativeTimeSpanString(time, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE) return getRelativeTimeSpanString(time, now, HOUR_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
} }
} }

View file

@ -17,12 +17,11 @@ import android.widget.TextView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.restore.RestorableBackup import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koin.androidx.viewmodel.ext.android.activityViewModel
class RestoreSetFragment : Fragment() { class RestoreSetFragment : Fragment() {
private val viewModel: RestoreViewModel by activityViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private lateinit var listView: RecyclerView private lateinit var listView: RecyclerView
private lateinit var progressBar: ProgressBar private lateinit var progressBar: ProgressBar

View file

@ -19,12 +19,11 @@ val restoreUiModule = module {
settingsManager = get(), settingsManager = get(),
keyManager = get(), keyManager = get(),
backupManager = get(), backupManager = get(),
appBackupManager = get(),
restoreCoordinator = get(), restoreCoordinator = get(),
apkRestore = get(), apkRestore = get(),
iconManager = get(), iconManager = get(),
storageBackup = get(), storageBackup = get(),
backendManager = get(), pluginManager = get(),
fileSelectionManager = get(), fileSelectionManager = get(),
) )
} }

View file

@ -17,10 +17,8 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.repo.AppBackupManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
@ -32,9 +30,6 @@ import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
import com.stevesoltys.seedvault.restore.install.InstallResult import com.stevesoltys.seedvault.restore.install.InstallResult
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.storage.StorageRestoreService import com.stevesoltys.seedvault.storage.StorageRestoreService
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
import com.stevesoltys.seedvault.transport.restore.RestorableBackupResult.ErrorResult
import com.stevesoltys.seedvault.transport.restore.RestorableBackupResult.SuccessResult
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent
@ -67,23 +62,22 @@ internal class RestoreViewModel(
keyManager: KeyManager, keyManager: KeyManager,
backupManager: IBackupManager, backupManager: IBackupManager,
private val restoreCoordinator: RestoreCoordinator, private val restoreCoordinator: RestoreCoordinator,
private val appBackupManager: AppBackupManager,
private val apkRestore: ApkRestore, private val apkRestore: ApkRestore,
private val iconManager: IconManager, private val iconManager: IconManager,
storageBackup: StorageBackup, storageBackup: StorageBackup,
backendManager: BackendManager, pluginManager: StoragePluginManager,
override val fileSelectionManager: FileSelectionManager, override val fileSelectionManager: FileSelectionManager,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : RequireProvisioningViewModel(app, settingsManager, keyManager, backendManager), ) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager),
RestorableBackupClickListener, SnapshotViewModel { RestorableBackupClickListener, SnapshotViewModel {
override val isRestoreOperation = true override val isRestoreOperation = true
var isSetupWizard = false var isSetupWizard = false
private val appSelectionManager = private val appSelectionManager =
AppSelectionManager(app, backendManager, iconManager, viewModelScope) AppSelectionManager(app, pluginManager, iconManager, viewModelScope)
private val appDataRestoreManager = AppDataRestoreManager( private val appDataRestoreManager = AppDataRestoreManager(
app, backupManager, restoreCoordinator, backendManager app, backupManager, settingsManager, restoreCoordinator, pluginManager
) )
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>() private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
@ -112,11 +106,20 @@ internal class RestoreViewModel(
private var storedSnapshot: StoredSnapshot? = null private var storedSnapshot: StoredSnapshot? = null
internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) { internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
val result = when (val backups = restoreCoordinator.getAvailableBackups()) { val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->
is ErrorResult -> RestoreSetResult( when (metadata.time) {
app.getString(R.string.restore_set_error) + "\n\n${backups.e}" 0L -> {
) Log.d(TAG, "Ignoring RestoreSet with no last backup time: $token.")
is SuccessResult -> RestoreSetResult(backups.backups) null
}
else -> RestorableBackup(metadata)
}
}
val result = when {
backups == null -> RestoreSetResult(app.getString(R.string.restore_set_error))
backups.isEmpty() -> RestoreSetResult(app.getString(R.string.restore_set_empty_result))
else -> RestoreSetResult(backups)
} }
mRestoreSetResults.postValue(result) mRestoreSetResults.postValue(result)
} }
@ -173,28 +176,11 @@ internal class RestoreViewModel(
super.onCleared() super.onCleared()
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(ioDispatcher) { iconManager.removeIcons() } GlobalScope.launch(ioDispatcher) { iconManager.removeIcons() }
appDataRestoreManager.closeSession()
} }
@UiThread @UiThread
internal fun onFinishClickedAfterRestoringAppData() { internal fun onFinishClickedAfterRestoringAppData() {
val backup = chosenRestorableBackup.value
if (appBackupManager.canRecycleBackupRepo(backup?.repoId, backup?.version)) {
mDisplayFragment.setEvent(RECYCLE_BACKUP)
} else {
mDisplayFragment.setEvent(RESTORE_FILES)
}
}
@UiThread
internal fun onRecycleBackupFinished(shouldRecycle: Boolean) {
val repoId = chosenRestorableBackup.value?.repoId
if (shouldRecycle && repoId != null) viewModelScope.launch(ioDispatcher) {
try {
appBackupManager.recycleBackupRepo(repoId)
} catch (e: Exception) {
Log.e(TAG, "Error transferring backup repo: ", e)
}
}
mDisplayFragment.setEvent(RESTORE_FILES) mDisplayFragment.setEvent(RESTORE_FILES)
} }
@ -239,7 +225,6 @@ internal enum class DisplayFragment {
SELECT_APPS, SELECT_APPS,
RESTORE_APPS, RESTORE_APPS,
RESTORE_BACKUP, RESTORE_BACKUP,
RECYCLE_BACKUP,
RESTORE_FILES, RESTORE_FILES,
RESTORE_SELECT_FILES, RESTORE_SELECT_FILES,
RESTORE_FILES_STARTED, RESTORE_FILES_STARTED,

View file

@ -11,19 +11,16 @@ import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.content.pm.SigningInfo
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.BackupStateManager import com.stevesoltys.seedvault.BackupStateManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.repo.Loader import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.repo.getBlobHandles import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.RestorableBackup
import com.stevesoltys.seedvault.restore.RestoreService import com.stevesoltys.seedvault.restore.RestoreService
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
@ -31,20 +28,14 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.transport.backup.isSystemApp import com.stevesoltys.seedvault.transport.backup.isSystemApp
import com.stevesoltys.seedvault.transport.restore.RestorableBackup import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
import com.stevesoltys.seedvault.worker.hashSignature import com.stevesoltys.seedvault.worker.getSignatures
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.GeneralSecurityException
import java.security.MessageDigest
import java.util.Locale import java.util.Locale
private val TAG = ApkRestore::class.java.simpleName private val TAG = ApkRestore::class.java.simpleName
@ -53,8 +44,7 @@ internal class ApkRestore(
private val context: Context, private val context: Context,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val backupStateManager: BackupStateManager, private val backupStateManager: BackupStateManager,
private val backendManager: BackendManager, private val pluginManager: StoragePluginManager,
private val loader: Loader,
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin, private val legacyStoragePlugin: LegacyStoragePlugin,
private val crypto: Crypto, private val crypto: Crypto,
@ -64,7 +54,7 @@ internal class ApkRestore(
) { ) {
private val pm = context.packageManager private val pm = context.packageManager
private val backend get() = backendManager.backend private val storagePlugin get() = pluginManager.appPlugin
private val mInstallResult = MutableStateFlow(InstallResult()) private val mInstallResult = MutableStateFlow(InstallResult())
val installResult = mInstallResult.asStateFlow() val installResult = mInstallResult.asStateFlow()
@ -75,7 +65,7 @@ internal class ApkRestore(
val packages = backup.packageMetadataMap.mapNotNull { (packageName, metadata) -> val packages = backup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
// We need to exclude the DocumentsProvider used to retrieve backup data. // We need to exclude the DocumentsProvider used to retrieve backup data.
// Otherwise, it gets killed when we install it, terminating our restoration. // Otherwise, it gets killed when we install it, terminating our restoration.
if (packageName == backend.providerPackageName) return@mapNotNull null if (packageName == storagePlugin.providerPackageName) return@mapNotNull null
// The @pm@ package needs to be included in [backup], but can't be installed like an app // The @pm@ package needs to be included in [backup], but can't be installed like an app
if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null if (packageName == MAGIC_PACKAGE_MANAGER) return@mapNotNull null
// we don't filter out apps without APK, so the user can manually install them // we don't filter out apps without APK, so the user can manually install them
@ -139,7 +129,6 @@ internal class ApkRestore(
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e) Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
mInstallResult.update { it.fail(packageName) } mInstallResult.update { it.fail(packageName) }
} catch (e: Exception) { } catch (e: Exception) {
if (e::class.simpleName == "MockKException") throw e
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e) Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
mInstallResult.update { it.fail(packageName) } mInstallResult.update { it.fail(packageName) }
} }
@ -164,12 +153,7 @@ internal class ApkRestore(
} }
@Suppress("ThrowsCount") @Suppress("ThrowsCount")
@Throws( @Throws(IOException::class, SecurityException::class)
GeneralSecurityException::class,
UnsupportedVersionException::class,
IOException::class,
SecurityException::class,
)
private suspend fun restore( private suspend fun restore(
backup: RestorableBackup, backup: RestorableBackup,
packageName: String, packageName: String,
@ -183,10 +167,10 @@ internal class ApkRestore(
} }
// cache the APK and get its hash // cache the APK and get its hash
val (cachedApk, sha256) = cacheApk(backup, packageName, metadata.baseApkChunkIds) val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
// check APK's SHA-256 hash for backup versions before 2 // check APK's SHA-256 hash
if (backup.version < 2 && metadata.sha256 != sha256) throw SecurityException( if (metadata.sha256 != sha256) throw SecurityException(
"Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected." "Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected."
) )
@ -252,7 +236,7 @@ internal class ApkRestore(
} }
/** /**
* Retrieves APK splits from [Backend] and caches them locally. * Retrieves APK splits from [StoragePlugin] and caches them locally.
* *
* @throws SecurityException if a split has an unexpected SHA-256 hash. * @throws SecurityException if a split has an unexpected SHA-256 hash.
* @return a list of all APKs that need to be installed * @return a list of all APKs that need to be installed
@ -277,9 +261,10 @@ internal class ApkRestore(
} }
splits.forEach { apkSplit -> // cache and check all splits splits.forEach { apkSplit -> // cache and check all splits
val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name
val (file, sha256) = cacheApk(backup, packageName, apkSplit.chunkIds, suffix) val salt = backup.salt
// check APK split's SHA-256 hash for backup versions before 2 val (file, sha256) = cacheApk(backup.version, backup.token, salt, packageName, suffix)
if (backup.version < 2 && apkSplit.sha256 != sha256) throw SecurityException( // check APK split's SHA-256 hash
if (apkSplit.sha256 != sha256) throw SecurityException(
"$packageName:${apkSplit.name} has sha256 '$sha256'," + "$packageName:${apkSplit.name} has sha256 '$sha256'," +
" but '${apkSplit.sha256}' expected." " but '${apkSplit.sha256}' expected."
) )
@ -289,37 +274,27 @@ internal class ApkRestore(
} }
/** /**
* Retrieves an APK from the [Backend] and caches it locally * Retrieves an APK from the [StoragePlugin] and caches it locally
* while calculating its SHA-256 hash. * while calculating its SHA-256 hash.
* *
* @return a [Pair] of the cached [File] and SHA-256 hash. * @return a [Pair] of the cached [File] and SHA-256 hash.
*/ */
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class) @Throws(IOException::class)
private suspend fun cacheApk( private suspend fun cacheApk(
backup: RestorableBackup, version: Byte,
token: Long,
salt: String,
packageName: String, packageName: String,
chunkIds: List<String>?,
suffix: String = "", suffix: String = "",
): Pair<File, String> { ): Pair<File, String> {
// create a cache file to write the APK into // create a cache file to write the APK into
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir) val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it // copy APK to cache file and calculate SHA-256 hash while we are at it
val inputStream = when (backup.version) { val inputStream = if (version == 0.toByte()) {
0.toByte() -> { legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
legacyStoragePlugin.getApkInputStream(backup.token, packageName, suffix) } else {
} val name = crypto.getNameForApk(salt, packageName, suffix)
1.toByte() -> { storagePlugin.getInputStream(token, name)
val name = crypto.getNameForApk(backup.salt, packageName, suffix)
backend.load(LegacyAppBackupFile.Blob(backup.token, name))
}
else -> {
val repoId = backup.repoId ?: error("No repoId for v2 backup")
val snapshot = backup.snapshot ?: error("No snapshot for v2 backup")
val handles = chunkIds?.let {
snapshot.getBlobHandles(repoId, it)
} ?: error("No chunkIds for $packageName-$suffix")
loader.loadFiles(handles)
}
} }
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream()) val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
return Pair(cachedApk, sha256) return Pair(cachedApk, sha256)
@ -367,45 +342,3 @@ internal class ApkRestore(
} }
} }
} }
/**
* Copy the APK from the given [InputStream] to the given [OutputStream]
* and calculate the SHA-256 hash while at it.
*
* Both streams will be closed when the method returns.
*
* @return the APK's SHA-256 hash in Base64 format.
*/
@Throws(IOException::class)
fun copyStreamsAndGetHash(inputStream: InputStream, outputStream: OutputStream): String {
val messageDigest = MessageDigest.getInstance("SHA-256")
outputStream.use { oStream ->
inputStream.use { inputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
oStream.write(buffer, 0, bytes)
messageDigest.update(buffer, 0, bytes)
bytes = inputStream.read(buffer)
}
}
}
return messageDigest.digest().encodeBase64()
}
/**
* Returns a list of Base64 encoded SHA-256 signature hashes.
*/
fun SigningInfo?.getSignatures(): List<String> {
return if (this == null) {
emptyList()
} else if (hasMultipleSigners()) {
apkContentsSigners.map { signature ->
hashSignature(signature).encodeBase64()
}
} else {
signingCertificateHistory.map { signature ->
hashSignature(signature).encodeBase64()
}
}
}

View file

@ -14,7 +14,7 @@ val installModule = module {
factory { DeviceInfo(androidContext()) } factory { DeviceInfo(androidContext()) }
factory { ApkSplitCompatibilityChecker(get()) } factory { ApkSplitCompatibilityChecker(get()) }
factory { factory {
ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) { ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get()) {
androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks() androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks()
} }
} }

View file

@ -84,7 +84,7 @@ internal class InstallProgressAdapter(
if (item.icon == null) iconJob = scope.launch { if (item.icon == null) iconJob = scope.launch {
iconLoader(item, appIcon::setImageDrawable) iconLoader(item, appIcon::setImageDrawable)
} else appIcon.setImageDrawable(item.icon) } else appIcon.setImageDrawable(item.icon)
appName.text = item.name ?: getAppName(v.context, item.packageName) appName.text = item.name ?: getAppName(v.context, item.packageName.toString())
appInfo.visibility = GONE appInfo.visibility = GONE
when (item.state) { when (item.state) {
IN_PROGRESS -> { IN_PROGRESS -> {

View file

@ -26,11 +26,11 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.restore.RestoreViewModel import com.stevesoltys.seedvault.restore.RestoreViewModel
import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class InstallProgressFragment : Fragment(), InstallItemListener { class InstallProgressFragment : Fragment(), InstallItemListener {
private val viewModel: RestoreViewModel by activityViewModel() private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context) private val layoutManager = LinearLayoutManager(context)
private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this) private val adapter = InstallProgressAdapter(lifecycleScope, this::loadIcon, this)

View file

@ -11,7 +11,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.TextView import android.widget.TextView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
@ -42,11 +41,12 @@ class AboutDialogFragment : Fragment() {
contributorsView.movementMethod = linkMovementMethod contributorsView.movementMethod = linkMovementMethod
orgsView.movementMethod = linkMovementMethod orgsView.movementMethod = linkMovementMethod
v.requireViewById<Toolbar>(R.id.toolbar).setNavigationOnClickListener {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
return v return v
} }
override fun onStart() {
super.onStart()
activity?.setTitle(R.string.about_title)
}
} }

View file

@ -9,6 +9,7 @@ import android.annotation.StringRes
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.appcompat.content.res.AppCompatResources.getDrawable import androidx.appcompat.content.res.AppCompatResources.getDrawable
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
@ -29,6 +30,8 @@ import com.stevesoltys.seedvault.ui.notification.getAppName
import com.stevesoltys.seedvault.ui.systemData import com.stevesoltys.seedvault.ui.systemData
import java.util.Locale import java.util.Locale
private const val TAG = "AppListRetriever"
sealed class AppListItem sealed class AppListItem
data class AppStatus( data class AppStatus(
@ -59,6 +62,7 @@ internal class AppListRetriever(
val appListSections = linkedMapOf( val appListSections = linkedMapOf(
AppSectionTitle(R.string.backup_section_system) to getSpecialApps(), AppSectionTitle(R.string.backup_section_system) to getSpecialApps(),
AppSectionTitle(R.string.backup_section_user) to getApps(), AppSectionTitle(R.string.backup_section_user) to getApps(),
AppSectionTitle(R.string.backup_section_not_allowed) to getNotAllowedApps()
).filter { it.value.isNotEmpty() } ).filter { it.value.isNotEmpty() }
return appListSections.flatMap { (sectionTitle, appList) -> return appListSections.flatMap { (sectionTitle, appList) ->
@ -77,7 +81,8 @@ internal class AppListRetriever(
AppStatus( AppStatus(
packageName = packageName, packageName = packageName,
enabled = settingsManager.isBackupEnabled(packageName), enabled = settingsManager.isBackupEnabled(packageName),
icon = getDrawable(context, data.iconRes) ?: getIconFromPackageManager(packageName), icon = data.iconRes?.let { getDrawable(context, it) }
?: getIconFromPackageManager(packageName),
name = context.getString(data.nameRes), name = context.getString(data.nameRes),
time = metadata?.time ?: 0, time = metadata?.time ?: 0,
size = metadata?.size, size = metadata?.size,
@ -94,11 +99,14 @@ internal class AppListRetriever(
val metadata = metadataManager.getPackageMetadata(it.packageName) val metadata = metadataManager.getPackageMetadata(it.packageName)
val time = metadata?.time ?: 0 val time = metadata?.time ?: 0
val status = metadata?.state.toAppBackupState() val status = metadata?.state.toAppBackupState()
if (status == NOT_YET_BACKED_UP) {
Log.w(TAG, "No metadata available for: ${it.packageName}")
}
AppStatus( AppStatus(
packageName = it.packageName, packageName = it.packageName,
enabled = settingsManager.isBackupEnabled(it.packageName), enabled = settingsManager.isBackupEnabled(it.packageName),
icon = getIconFromPackageManager(it.packageName), icon = getIconFromPackageManager(it.packageName),
name = metadata?.name?.toString() ?: getAppName(context, it.packageName).toString(), name = getAppName(context, it.packageName).toString(),
time = time, time = time,
size = metadata?.size, size = metadata?.size,
status = status, status = status,
@ -107,18 +115,13 @@ internal class AppListRetriever(
val locale = Locale.getDefault() val locale = Locale.getDefault()
return (userApps + packageService.launchableSystemApps.mapNotNull { return (userApps + packageService.launchableSystemApps.mapNotNull {
val packageName = it.activityInfo.packageName val packageName = it.activityInfo.packageName
if (packageName in userPackages || packageName == context.packageName) { if (packageName in userPackages) return@mapNotNull null
// don't re-add user packages again,
// also on some ROMs we are a launchableSystemApp, so we need to exclude ourselves
return@mapNotNull null
}
val metadata = metadataManager.getPackageMetadata(packageName) val metadata = metadataManager.getPackageMetadata(packageName)
AppStatus( AppStatus(
packageName = packageName, packageName = packageName,
enabled = settingsManager.isBackupEnabled(packageName), enabled = settingsManager.isBackupEnabled(packageName),
icon = getIconFromPackageManager(packageName), icon = getIconFromPackageManager(packageName),
name = metadata?.name?.toString() name = it.loadLabel(context.packageManager).toString(),
?: it.loadLabel(context.packageManager).toString(),
time = metadata?.time ?: 0, time = metadata?.time ?: 0,
size = metadata?.size, size = metadata?.size,
status = metadata?.state.toAppBackupState(), status = metadata?.state.toAppBackupState(),
@ -126,6 +129,21 @@ internal class AppListRetriever(
}).sortedBy { it.name.lowercase(locale) } }).sortedBy { it.name.lowercase(locale) }
} }
private fun getNotAllowedApps(): List<AppStatus> {
val locale = Locale.getDefault()
return packageService.userNotAllowedApps.map {
AppStatus(
packageName = it.packageName,
enabled = settingsManager.isBackupEnabled(it.packageName),
icon = getIconFromPackageManager(it.packageName),
name = getAppName(context, it.packageName).toString(),
time = 0,
size = null,
status = FAILED_NOT_ALLOWED,
)
}.sortedBy { it.name.lowercase(locale) }
}
private fun getIconFromPackageManager(packageName: String): Drawable = try { private fun getIconFromPackageManager(packageName: String): Drawable = try {
pm.getApplicationIcon(packageName) pm.getApplicationIcon(packageName)
} catch (e: PackageManager.NameNotFoundException) { } catch (e: PackageManager.NameNotFoundException) {

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