Merge pull request #750 from grote/app-backup-v2
App backup format v2 with compression and deduplication
This commit is contained in:
commit
5365ef3a5e
213 changed files with 10054 additions and 4090 deletions
4
.github/scripts/run_tests.sh
vendored
4
.github/scripts/run_tests.sh
vendored
|
@ -10,10 +10,8 @@ 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 -Pd2d_backup_test="$D2D_BACKUP_TEST" :app:connectedAndroidTest || large_test_exit_code=$?
|
./gradlew --stacktrace -Pinstrumented_test_size=large :app:connectedAndroidTest || large_test_exit_code=$?
|
||||||
|
|
||||||
adb pull /sdcard/seedvault_test_results
|
adb pull /sdcard/seedvault_test_results
|
||||||
|
|
||||||
|
|
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
|
@ -20,7 +20,6 @@ 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
|
||||||
|
@ -53,7 +52,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 ${{ matrix.d2d_backup_test }}
|
./.github/scripts/run_tests.sh
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
|
|
|
@ -1,12 +1,7 @@
|
||||||
<component name="ProjectCodeStyleConfiguration">
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
<code_scheme name="Project" version="173">
|
<code_scheme name="Project" version="173">
|
||||||
<JetCodeStyleSettings>
|
<JetCodeStyleSettings>
|
||||||
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
<option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" />
|
||||||
<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">
|
||||||
|
|
22
Android.bp
22
Android.bp
|
@ -8,12 +8,23 @@ 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",
|
||||||
|
@ -26,6 +37,13 @@ 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",
|
||||||
|
// 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
|
// our own gradle module libs
|
||||||
"seedvault-lib-core",
|
"seedvault-lib-core",
|
||||||
"seedvault-lib-storage",
|
"seedvault-lib-storage",
|
||||||
|
@ -34,10 +52,8 @@ android_app {
|
||||||
"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",
|
|
||||||
],
|
],
|
||||||
|
use_embedded_native_libs: true,
|
||||||
manifest: "app/src/main/AndroidManifest.xml",
|
manifest: "app/src/main/AndroidManifest.xml",
|
||||||
|
|
||||||
platform_apis: true,
|
platform_apis: true,
|
||||||
|
|
|
@ -3,12 +3,14 @@
|
||||||
// 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 = {
|
||||||
|
@ -37,9 +39,6 @@ android {
|
||||||
|
|
||||||
testInstrumentationRunnerArguments["size"] = testSize
|
testInstrumentationRunnerArguments["size"] = testSize
|
||||||
}
|
}
|
||||||
|
|
||||||
val d2dBackupTest = project.findProperty("d2d_backup_test")?.toString() ?: "true"
|
|
||||||
testInstrumentationRunnerArguments["d2d_backup_test"] = d2dBackupTest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
@ -93,6 +92,30 @@ 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
|
||||||
|
|
||||||
|
@ -132,7 +155,10 @@ 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
|
||||||
|
@ -151,9 +177,13 @@ 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(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
|
implementation(
|
||||||
|
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)
|
||||||
|
@ -163,6 +193,7 @@ 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()}")
|
||||||
|
@ -173,6 +204,7 @@ 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()}")
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,950 @@
|
||||||
|
//Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
// source: snapshot.proto
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.proto;
|
||||||
|
|
||||||
|
@kotlin.jvm.JvmName("-initializesnapshot")
|
||||||
|
public inline fun snapshot(block: com.stevesoltys.seedvault.proto.SnapshotKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.newBuilder()).apply { block() }._build()
|
||||||
|
public object SnapshotKt {
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||||
|
public class Dsl private constructor(
|
||||||
|
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Builder
|
||||||
|
) {
|
||||||
|
public companion object {
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Builder): Dsl = Dsl(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot = _builder.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>uint32 version = 1;</code>
|
||||||
|
*/
|
||||||
|
public var version: kotlin.Int
|
||||||
|
@JvmName("getVersion")
|
||||||
|
get() = _builder.getVersion()
|
||||||
|
@JvmName("setVersion")
|
||||||
|
set(value) {
|
||||||
|
_builder.setVersion(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>uint32 version = 1;</code>
|
||||||
|
*/
|
||||||
|
public fun clearVersion() {
|
||||||
|
_builder.clearVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>uint64 token = 2;</code>
|
||||||
|
*/
|
||||||
|
public var token: kotlin.Long
|
||||||
|
@JvmName("getToken")
|
||||||
|
get() = _builder.getToken()
|
||||||
|
@JvmName("setToken")
|
||||||
|
set(value) {
|
||||||
|
_builder.setToken(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>uint64 token = 2;</code>
|
||||||
|
*/
|
||||||
|
public fun clearToken() {
|
||||||
|
_builder.clearToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>string name = 3;</code>
|
||||||
|
*/
|
||||||
|
public var name: kotlin.String
|
||||||
|
@JvmName("getName")
|
||||||
|
get() = _builder.getName()
|
||||||
|
@JvmName("setName")
|
||||||
|
set(value) {
|
||||||
|
_builder.setName(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>string name = 3;</code>
|
||||||
|
*/
|
||||||
|
public fun clearName() {
|
||||||
|
_builder.clearName()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>string user = 4;</code>
|
||||||
|
*/
|
||||||
|
public var user: kotlin.String
|
||||||
|
@JvmName("getUser")
|
||||||
|
get() = _builder.getUser()
|
||||||
|
@JvmName("setUser")
|
||||||
|
set(value) {
|
||||||
|
_builder.setUser(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>string user = 4;</code>
|
||||||
|
*/
|
||||||
|
public fun clearUser() {
|
||||||
|
_builder.clearUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>string androidId = 5;</code>
|
||||||
|
*/
|
||||||
|
public var androidId: kotlin.String
|
||||||
|
@JvmName("getAndroidId")
|
||||||
|
get() = _builder.getAndroidId()
|
||||||
|
@JvmName("setAndroidId")
|
||||||
|
set(value) {
|
||||||
|
_builder.setAndroidId(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>string androidId = 5;</code>
|
||||||
|
*/
|
||||||
|
public fun clearAndroidId() {
|
||||||
|
_builder.clearAndroidId()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>uint32 sdkInt = 6;</code>
|
||||||
|
*/
|
||||||
|
public var sdkInt: kotlin.Int
|
||||||
|
@JvmName("getSdkInt")
|
||||||
|
get() = _builder.getSdkInt()
|
||||||
|
@JvmName("setSdkInt")
|
||||||
|
set(value) {
|
||||||
|
_builder.setSdkInt(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>uint32 sdkInt = 6;</code>
|
||||||
|
*/
|
||||||
|
public fun clearSdkInt() {
|
||||||
|
_builder.clearSdkInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>string androidIncremental = 7;</code>
|
||||||
|
*/
|
||||||
|
public var androidIncremental: kotlin.String
|
||||||
|
@JvmName("getAndroidIncremental")
|
||||||
|
get() = _builder.getAndroidIncremental()
|
||||||
|
@JvmName("setAndroidIncremental")
|
||||||
|
set(value) {
|
||||||
|
_builder.setAndroidIncremental(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>string androidIncremental = 7;</code>
|
||||||
|
*/
|
||||||
|
public fun clearAndroidIncremental() {
|
||||||
|
_builder.clearAndroidIncremental()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>bool d2d = 8;</code>
|
||||||
|
*/
|
||||||
|
public var d2D: kotlin.Boolean
|
||||||
|
@JvmName("getD2D")
|
||||||
|
get() = _builder.getD2D()
|
||||||
|
@JvmName("setD2D")
|
||||||
|
set(value) {
|
||||||
|
_builder.setD2D(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>bool d2d = 8;</code>
|
||||||
|
*/
|
||||||
|
public fun clearD2D() {
|
||||||
|
_builder.clearD2D()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An uninstantiable, behaviorless type to represent the field in
|
||||||
|
* generics.
|
||||||
|
*/
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
public class AppsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||||
|
*/
|
||||||
|
public val apps: com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("getAppsMap")
|
||||||
|
get() = com.google.protobuf.kotlin.DslMap(
|
||||||
|
_builder.getAppsMap()
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||||
|
*/
|
||||||
|
@JvmName("putApps")
|
||||||
|
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||||
|
.put(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.App) {
|
||||||
|
_builder.putApps(key, value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("setApps")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||||
|
.set(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.App) {
|
||||||
|
put(key, value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("removeApps")
|
||||||
|
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||||
|
.remove(key: kotlin.String) {
|
||||||
|
_builder.removeApps(key)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("putAllApps")
|
||||||
|
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||||
|
.putAll(map: kotlin.collections.Map<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App>) {
|
||||||
|
_builder.putAllApps(map)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.App> apps = 9;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("clearApps")
|
||||||
|
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
|
||||||
|
.clear() {
|
||||||
|
_builder.clearApps()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An uninstantiable, behaviorless type to represent the field in
|
||||||
|
* generics.
|
||||||
|
*/
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
public class IconChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||||
|
/**
|
||||||
|
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||||
|
*/
|
||||||
|
public val iconChunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
get() = com.google.protobuf.kotlin.DslList(
|
||||||
|
_builder.getIconChunkIdsList()
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||||
|
* @param value The iconChunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addIconChunkIds")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
|
||||||
|
_builder.addIconChunkIds(value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||||
|
* @param value The iconChunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignIconChunkIds")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
||||||
|
add(value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||||
|
* @param values The iconChunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addAllIconChunkIds")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||||
|
_builder.addAllIconChunkIds(values)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||||
|
* @param values The iconChunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignAllIconChunkIds")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||||
|
addAll(values)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||||
|
* @param index The index to set the value at.
|
||||||
|
* @param value The iconChunkIds to set.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("setIconChunkIds")
|
||||||
|
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
||||||
|
_builder.setIconChunkIds(index, value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes iconChunkIds = 10;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("clearIconChunkIds")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.clear() {
|
||||||
|
_builder.clearIconChunkIds()
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* An uninstantiable, behaviorless type to represent the field in
|
||||||
|
* generics.
|
||||||
|
*/
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
public class BlobsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||||
|
*/
|
||||||
|
public val blobs: com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("getBlobsMap")
|
||||||
|
get() = com.google.protobuf.kotlin.DslMap(
|
||||||
|
_builder.getBlobsMap()
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||||
|
*/
|
||||||
|
@JvmName("putBlobs")
|
||||||
|
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||||
|
.put(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.Blob) {
|
||||||
|
_builder.putBlobs(key, value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("setBlobs")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||||
|
.set(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.Blob) {
|
||||||
|
put(key, value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("removeBlobs")
|
||||||
|
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||||
|
.remove(key: kotlin.String) {
|
||||||
|
_builder.removeBlobs(key)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("putAllBlobs")
|
||||||
|
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||||
|
.putAll(map: kotlin.collections.Map<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob>) {
|
||||||
|
_builder.putAllBlobs(map)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>map<string, .com.stevesoltys.seedvault.proto.Snapshot.Blob> blobs = 11;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@JvmName("clearBlobs")
|
||||||
|
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
|
||||||
|
.clear() {
|
||||||
|
_builder.clearBlobs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@kotlin.jvm.JvmName("-initializeapp")
|
||||||
|
public inline fun app(block: com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.App =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.App.newBuilder()).apply { block() }._build()
|
||||||
|
public object AppKt {
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||||
|
public class Dsl private constructor(
|
||||||
|
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.App.Builder
|
||||||
|
) {
|
||||||
|
public companion object {
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.App.Builder): Dsl = Dsl(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.App = _builder.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>uint64 time = 1;</code>
|
||||||
|
*/
|
||||||
|
public var time: kotlin.Long
|
||||||
|
@JvmName("getTime")
|
||||||
|
get() = _builder.getTime()
|
||||||
|
@JvmName("setTime")
|
||||||
|
set(value) {
|
||||||
|
_builder.setTime(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>uint64 time = 1;</code>
|
||||||
|
*/
|
||||||
|
public fun clearTime() {
|
||||||
|
_builder.clearTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>.com.stevesoltys.seedvault.proto.Snapshot.BackupType type = 2;</code>
|
||||||
|
*/
|
||||||
|
public var type: com.stevesoltys.seedvault.proto.Snapshot.BackupType
|
||||||
|
@JvmName("getType")
|
||||||
|
get() = _builder.getType()
|
||||||
|
@JvmName("setType")
|
||||||
|
set(value) {
|
||||||
|
_builder.setType(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>.com.stevesoltys.seedvault.proto.Snapshot.BackupType type = 2;</code>
|
||||||
|
*/
|
||||||
|
public fun clearType() {
|
||||||
|
_builder.clearType()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>string name = 3;</code>
|
||||||
|
*/
|
||||||
|
public var name: kotlin.String
|
||||||
|
@JvmName("getName")
|
||||||
|
get() = _builder.getName()
|
||||||
|
@JvmName("setName")
|
||||||
|
set(value) {
|
||||||
|
_builder.setName(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>string name = 3;</code>
|
||||||
|
*/
|
||||||
|
public fun clearName() {
|
||||||
|
_builder.clearName()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>bool system = 4;</code>
|
||||||
|
*/
|
||||||
|
public var system: kotlin.Boolean
|
||||||
|
@JvmName("getSystem")
|
||||||
|
get() = _builder.getSystem()
|
||||||
|
@JvmName("setSystem")
|
||||||
|
set(value) {
|
||||||
|
_builder.setSystem(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>bool system = 4;</code>
|
||||||
|
*/
|
||||||
|
public fun clearSystem() {
|
||||||
|
_builder.clearSystem()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>bool launchableSystemApp = 5;</code>
|
||||||
|
*/
|
||||||
|
public var launchableSystemApp: kotlin.Boolean
|
||||||
|
@JvmName("getLaunchableSystemApp")
|
||||||
|
get() = _builder.getLaunchableSystemApp()
|
||||||
|
@JvmName("setLaunchableSystemApp")
|
||||||
|
set(value) {
|
||||||
|
_builder.setLaunchableSystemApp(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>bool launchableSystemApp = 5;</code>
|
||||||
|
*/
|
||||||
|
public fun clearLaunchableSystemApp() {
|
||||||
|
_builder.clearLaunchableSystemApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An uninstantiable, behaviorless type to represent the field in
|
||||||
|
* generics.
|
||||||
|
*/
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
public class ChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||||
|
/**
|
||||||
|
* <code>repeated bytes chunkIds = 6;</code>
|
||||||
|
*/
|
||||||
|
public val chunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
get() = com.google.protobuf.kotlin.DslList(
|
||||||
|
_builder.getChunkIdsList()
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* <code>repeated bytes chunkIds = 6;</code>
|
||||||
|
* @param value The chunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addChunkIds")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
|
||||||
|
_builder.addChunkIds(value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 6;</code>
|
||||||
|
* @param value The chunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignChunkIds")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
||||||
|
add(value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 6;</code>
|
||||||
|
* @param values The chunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addAllChunkIds")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||||
|
_builder.addAllChunkIds(values)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 6;</code>
|
||||||
|
* @param values The chunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignAllChunkIds")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||||
|
addAll(values)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 6;</code>
|
||||||
|
* @param index The index to set the value at.
|
||||||
|
* @param value The chunkIds to set.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("setChunkIds")
|
||||||
|
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
||||||
|
_builder.setChunkIds(index, value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 6;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("clearChunkIds")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.clear() {
|
||||||
|
_builder.clearChunkIds()
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
|
||||||
|
*/
|
||||||
|
public var apk: com.stevesoltys.seedvault.proto.Snapshot.Apk
|
||||||
|
@JvmName("getApk")
|
||||||
|
get() = _builder.getApk()
|
||||||
|
@JvmName("setApk")
|
||||||
|
set(value) {
|
||||||
|
_builder.setApk(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
|
||||||
|
*/
|
||||||
|
public fun clearApk() {
|
||||||
|
_builder.clearApk()
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
|
||||||
|
* @return Whether the apk field is set.
|
||||||
|
*/
|
||||||
|
public fun hasApk(): kotlin.Boolean {
|
||||||
|
return _builder.hasApk()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>uint64 size = 8;</code>
|
||||||
|
*/
|
||||||
|
public var size: kotlin.Long
|
||||||
|
@JvmName("getSize")
|
||||||
|
get() = _builder.getSize()
|
||||||
|
@JvmName("setSize")
|
||||||
|
set(value) {
|
||||||
|
_builder.setSize(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>uint64 size = 8;</code>
|
||||||
|
*/
|
||||||
|
public fun clearSize() {
|
||||||
|
_builder.clearSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@kotlin.jvm.JvmName("-initializeapk")
|
||||||
|
public inline fun apk(block: com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Apk =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Apk.newBuilder()).apply { block() }._build()
|
||||||
|
public object ApkKt {
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||||
|
public class Dsl private constructor(
|
||||||
|
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Apk.Builder
|
||||||
|
) {
|
||||||
|
public companion object {
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Apk.Builder): Dsl = Dsl(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Apk = _builder.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
**
|
||||||
|
* Attention: Has default value of 0
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <code>uint64 versionCode = 1;</code>
|
||||||
|
*/
|
||||||
|
public var versionCode: kotlin.Long
|
||||||
|
@JvmName("getVersionCode")
|
||||||
|
get() = _builder.getVersionCode()
|
||||||
|
@JvmName("setVersionCode")
|
||||||
|
set(value) {
|
||||||
|
_builder.setVersionCode(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <pre>
|
||||||
|
**
|
||||||
|
* Attention: Has default value of 0
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <code>uint64 versionCode = 1;</code>
|
||||||
|
*/
|
||||||
|
public fun clearVersionCode() {
|
||||||
|
_builder.clearVersionCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>string installer = 2;</code>
|
||||||
|
*/
|
||||||
|
public var installer: kotlin.String
|
||||||
|
@JvmName("getInstaller")
|
||||||
|
get() = _builder.getInstaller()
|
||||||
|
@JvmName("setInstaller")
|
||||||
|
set(value) {
|
||||||
|
_builder.setInstaller(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>string installer = 2;</code>
|
||||||
|
*/
|
||||||
|
public fun clearInstaller() {
|
||||||
|
_builder.clearInstaller()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An uninstantiable, behaviorless type to represent the field in
|
||||||
|
* generics.
|
||||||
|
*/
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
public class SignaturesProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||||
|
/**
|
||||||
|
* <code>repeated bytes signatures = 3;</code>
|
||||||
|
*/
|
||||||
|
public val signatures: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
get() = com.google.protobuf.kotlin.DslList(
|
||||||
|
_builder.getSignaturesList()
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* <code>repeated bytes signatures = 3;</code>
|
||||||
|
* @param value The signatures to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addSignatures")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.add(value: com.google.protobuf.ByteString) {
|
||||||
|
_builder.addSignatures(value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes signatures = 3;</code>
|
||||||
|
* @param value The signatures to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignSignatures")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
||||||
|
add(value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes signatures = 3;</code>
|
||||||
|
* @param values The signatures to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addAllSignatures")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||||
|
_builder.addAllSignatures(values)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes signatures = 3;</code>
|
||||||
|
* @param values The signatures to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignAllSignatures")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||||
|
addAll(values)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes signatures = 3;</code>
|
||||||
|
* @param index The index to set the value at.
|
||||||
|
* @param value The signatures to set.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("setSignatures")
|
||||||
|
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
||||||
|
_builder.setSignatures(index, value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes signatures = 3;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("clearSignatures")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.clear() {
|
||||||
|
_builder.clearSignatures()
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* An uninstantiable, behaviorless type to represent the field in
|
||||||
|
* generics.
|
||||||
|
*/
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
public class SplitsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||||
|
/**
|
||||||
|
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||||
|
*/
|
||||||
|
public val splits: com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
get() = com.google.protobuf.kotlin.DslList(
|
||||||
|
_builder.getSplitsList()
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||||
|
* @param value The splits to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addSplits")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.add(value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
|
||||||
|
_builder.addSplits(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||||
|
* @param value The splits to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignSplits")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.plusAssign(value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
|
||||||
|
add(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||||
|
* @param values The splits to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addAllSplits")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.addAll(values: kotlin.collections.Iterable<com.stevesoltys.seedvault.proto.Snapshot.Split>) {
|
||||||
|
_builder.addAllSplits(values)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||||
|
* @param values The splits to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignAllSplits")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.plusAssign(values: kotlin.collections.Iterable<com.stevesoltys.seedvault.proto.Snapshot.Split>) {
|
||||||
|
addAll(values)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||||
|
* @param index The index to set the value at.
|
||||||
|
* @param value The splits to set.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("setSplits")
|
||||||
|
public operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.set(index: kotlin.Int, value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
|
||||||
|
_builder.setSplits(index, value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("clearSplits")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.clear() {
|
||||||
|
_builder.clearSplits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@kotlin.jvm.JvmName("-initializesplit")
|
||||||
|
public inline fun split(block: com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Split =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Split.newBuilder()).apply { block() }._build()
|
||||||
|
public object SplitKt {
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||||
|
public class Dsl private constructor(
|
||||||
|
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Split.Builder
|
||||||
|
) {
|
||||||
|
public companion object {
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Split.Builder): Dsl = Dsl(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Split = _builder.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>string name = 1;</code>
|
||||||
|
*/
|
||||||
|
public var name: kotlin.String
|
||||||
|
@JvmName("getName")
|
||||||
|
get() = _builder.getName()
|
||||||
|
@JvmName("setName")
|
||||||
|
set(value) {
|
||||||
|
_builder.setName(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>string name = 1;</code>
|
||||||
|
*/
|
||||||
|
public fun clearName() {
|
||||||
|
_builder.clearName()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An uninstantiable, behaviorless type to represent the field in
|
||||||
|
* generics.
|
||||||
|
*/
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
public class ChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
|
||||||
|
/**
|
||||||
|
* <code>repeated bytes chunkIds = 2;</code>
|
||||||
|
*/
|
||||||
|
public val chunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
get() = com.google.protobuf.kotlin.DslList(
|
||||||
|
_builder.getChunkIdsList()
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* <code>repeated bytes chunkIds = 2;</code>
|
||||||
|
* @param value The chunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addChunkIds")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
|
||||||
|
_builder.addChunkIds(value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 2;</code>
|
||||||
|
* @param value The chunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignChunkIds")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
|
||||||
|
add(value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 2;</code>
|
||||||
|
* @param values The chunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("addAllChunkIds")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||||
|
_builder.addAllChunkIds(values)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 2;</code>
|
||||||
|
* @param values The chunkIds to add.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("plusAssignAllChunkIds")
|
||||||
|
@Suppress("NOTHING_TO_INLINE")
|
||||||
|
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
|
||||||
|
addAll(values)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 2;</code>
|
||||||
|
* @param index The index to set the value at.
|
||||||
|
* @param value The chunkIds to set.
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("setChunkIds")
|
||||||
|
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
|
||||||
|
_builder.setChunkIds(index, value)
|
||||||
|
}/**
|
||||||
|
* <code>repeated bytes chunkIds = 2;</code>
|
||||||
|
*/
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.jvm.JvmName("clearChunkIds")
|
||||||
|
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.clear() {
|
||||||
|
_builder.clearChunkIds()
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
@kotlin.jvm.JvmName("-initializeblob")
|
||||||
|
public inline fun blob(block: com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Blob =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Blob.newBuilder()).apply { block() }._build()
|
||||||
|
public object BlobKt {
|
||||||
|
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
|
||||||
|
@com.google.protobuf.kotlin.ProtoDslMarker
|
||||||
|
public class Dsl private constructor(
|
||||||
|
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Blob.Builder
|
||||||
|
) {
|
||||||
|
public companion object {
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Blob.Builder): Dsl = Dsl(builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
@kotlin.jvm.JvmSynthetic
|
||||||
|
@kotlin.PublishedApi
|
||||||
|
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Blob = _builder.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>bytes id = 1;</code>
|
||||||
|
*/
|
||||||
|
public var id: com.google.protobuf.ByteString
|
||||||
|
@JvmName("getId")
|
||||||
|
get() = _builder.getId()
|
||||||
|
@JvmName("setId")
|
||||||
|
set(value) {
|
||||||
|
_builder.setId(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>bytes id = 1;</code>
|
||||||
|
*/
|
||||||
|
public fun clearId() {
|
||||||
|
_builder.clearId()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>uint32 length = 2;</code>
|
||||||
|
*/
|
||||||
|
public var length: kotlin.Int
|
||||||
|
@JvmName("getLength")
|
||||||
|
get() = _builder.getLength()
|
||||||
|
@JvmName("setLength")
|
||||||
|
set(value) {
|
||||||
|
_builder.setLength(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>uint32 length = 2;</code>
|
||||||
|
*/
|
||||||
|
public fun clearLength() {
|
||||||
|
_builder.clearLength()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <code>uint32 uncompressedLength = 3;</code>
|
||||||
|
*/
|
||||||
|
public var uncompressedLength: kotlin.Int
|
||||||
|
@JvmName("getUncompressedLength")
|
||||||
|
get() = _builder.getUncompressedLength()
|
||||||
|
@JvmName("setUncompressedLength")
|
||||||
|
set(value) {
|
||||||
|
_builder.setUncompressedLength(value)
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* <code>uint32 uncompressedLength = 3;</code>
|
||||||
|
*/
|
||||||
|
public fun clearUncompressedLength() {
|
||||||
|
_builder.clearUncompressedLength()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public inline fun com.stevesoltys.seedvault.proto.Snapshot.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||||
|
|
||||||
|
public inline fun com.stevesoltys.seedvault.proto.Snapshot.App.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.App =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||||
|
|
||||||
|
public val com.stevesoltys.seedvault.proto.Snapshot.AppOrBuilder.apkOrNull: com.stevesoltys.seedvault.proto.Snapshot.Apk?
|
||||||
|
get() = if (hasApk()) getApk() else null
|
||||||
|
|
||||||
|
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Apk.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Apk =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||||
|
|
||||||
|
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Split.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Split =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||||
|
|
||||||
|
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Blob.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Blob =
|
||||||
|
com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl._create(this.toBuilder()).apply { block() }._build()
|
||||||
|
|
|
@ -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(), get())) }
|
single { spyk(PackageService(context, 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(), get())) }
|
single { spyk(FullBackup(get(), get(), get(), get())) }
|
||||||
single { spyk(KVBackup(get(), get(), get(), get(), get(), get())) }
|
single { spyk(KVBackup(get(), get(), get())) }
|
||||||
single { spyk(InputFactory()) }
|
single { spyk(InputFactory()) }
|
||||||
|
|
||||||
single { spyk(FullRestore(get(), get(), get(), get(), get())) }
|
single { spyk(FullRestore(get(), get(), get(), get(), get(), get())) }
|
||||||
single { spyk(KVRestore(get(), get(), get(), get(), get(), get())) }
|
single { spyk(KVRestore(get(), get(), get(), get(), get(), get(), get())) }
|
||||||
single { spyk(OutputFactory()) }
|
single { spyk(OutputFactory()) }
|
||||||
|
|
||||||
viewModel {
|
viewModel {
|
||||||
|
@ -53,6 +53,7 @@ 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(),
|
||||||
|
|
|
@ -10,7 +10,6 @@ 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.backend.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.backend.getAvailableBackups
|
|
||||||
import com.stevesoltys.seedvault.backend.saf.DocumentsProviderLegacyPlugin
|
import com.stevesoltys.seedvault.backend.saf.DocumentsProviderLegacyPlugin
|
||||||
import com.stevesoltys.seedvault.backend.saf.DocumentsStorage
|
import com.stevesoltys.seedvault.backend.saf.DocumentsStorage
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
@ -92,59 +91,58 @@ 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.getAvailableBackups()?.toList()?.size)
|
assertEquals(0, backend.getAvailableBackupFileHandles().toList().size)
|
||||||
|
|
||||||
// prepare returned tokens requested when initializing device
|
// prepare returned tokens requested when initializing device
|
||||||
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
|
every { mockedSettingsManager.token } returnsMany listOf(token, token + 1, token + 1)
|
||||||
|
|
||||||
// write metadata (needed for backup to be recognized)
|
// write metadata (needed for backup to be recognized)
|
||||||
backend.save(LegacyAppBackupFile.Metadata(token))
|
backend.save(LegacyAppBackupFile.Metadata(token))
|
||||||
.writeAndClose(getRandomByteArray())
|
.writeAndClose(getRandomByteArray())
|
||||||
|
|
||||||
// one backup available now
|
// one backup available now
|
||||||
assertEquals(1, backend.getAvailableBackups()?.toList()?.size)
|
assertEquals(1, backend.getAvailableBackupFileHandles().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))
|
backend.save(LegacyAppBackupFile.Metadata(token + 1))
|
||||||
.writeAndClose(getRandomByteArray())
|
.writeAndClose(getRandomByteArray())
|
||||||
assertEquals(2, backend.getAvailableBackups()?.toList()?.size)
|
assertEquals(2, backend.getAvailableBackupFileHandles().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))
|
backend.save(LegacyAppBackupFile.Metadata(token + 1))
|
||||||
.writeAndClose(getRandomByteArray())
|
.writeAndClose(getRandomByteArray())
|
||||||
assertEquals(2, backend.getAvailableBackups()?.toList()?.size)
|
assertEquals(2, backend.getAvailableBackupFileHandles().toList().size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
|
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
|
||||||
every { mockedSettingsManager.getToken() } returns token
|
every { mockedSettingsManager.token } returns token
|
||||||
|
|
||||||
// write metadata
|
// write metadata
|
||||||
val metadata = getRandomByteArray()
|
val metadata = getRandomByteArray()
|
||||||
backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata)
|
backend.save(LegacyAppBackupFile.Metadata(token)).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.getAvailableBackups()?.toList()
|
var availableBackups = backend.getAvailableBackupFileHandles().toList()
|
||||||
check(availableBackups != null)
|
|
||||||
assertEquals(1, availableBackups.size)
|
assertEquals(1, availableBackups.size)
|
||||||
assertEquals(token, availableBackups[0].token)
|
var backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata
|
||||||
|
assertEquals(token, backupHandle.token)
|
||||||
|
|
||||||
// read metadata matches what was written earlier
|
// read metadata matches what was written earlier
|
||||||
assertReadEquals(metadata, availableBackups[0].inputStreamRetriever())
|
assertReadEquals(metadata, backend.load(backupHandle))
|
||||||
|
|
||||||
// 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)
|
backend.save(LegacyAppBackupFile.Metadata(token)).writeAndClose(metadata)
|
||||||
availableBackups = backend.getAvailableBackups()?.toList()
|
availableBackups = backend.getAvailableBackupFileHandles().toList()
|
||||||
check(availableBackups != null)
|
|
||||||
assertEquals(1, availableBackups.size)
|
assertEquals(1, availableBackups.size)
|
||||||
assertEquals(token, availableBackups[0].token)
|
backupHandle = availableBackups[0] as LegacyAppBackupFile.Metadata
|
||||||
|
assertEquals(token, backupHandle.token)
|
||||||
|
|
||||||
// metadata hasn't changed
|
// metadata hasn't changed
|
||||||
assertReadEquals(metadata, availableBackups[0].inputStreamRetriever())
|
assertReadEquals(metadata, backend.load(backupHandle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Suppress("Deprecation")
|
|
||||||
fun v0testApkWriteRead() = runBlocking {
|
fun v0testApkWriteRead() = runBlocking {
|
||||||
// initialize storage with given token
|
// initialize storage with given token
|
||||||
initStorage(token)
|
initStorage(token)
|
||||||
|
@ -202,7 +200,7 @@ class PluginTest : KoinComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initStorage(token: Long) = runBlocking {
|
private fun initStorage(token: Long) = runBlocking {
|
||||||
every { mockedSettingsManager.getToken() } returns token
|
every { mockedSettingsManager.token } returns token
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,6 @@ import kotlinx.coroutines.runBlocking
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.BackendTest
|
import org.calyxos.seedvault.core.backends.BackendTest
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
@ -25,15 +24,8 @@ class SafBackendTest : BackendTest(), KoinComponent {
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
private val settingsManager by inject<SettingsManager>()
|
private val settingsManager by inject<SettingsManager>()
|
||||||
private val safStorage = settingsManager.getSafProperties() ?: error("No SAF storage")
|
private val safProperties = settingsManager.getSafProperties() ?: error("No SAF storage")
|
||||||
private val safProperties = SafProperties(
|
override val backend: Backend = SafBackend(context, safProperties, ".SeedvaultTest")
|
||||||
config = safStorage.config,
|
|
||||||
name = safStorage.name,
|
|
||||||
isUsb = safStorage.isUsb,
|
|
||||||
requiresNetwork = safStorage.requiresNetwork,
|
|
||||||
rootId = safStorage.rootId,
|
|
||||||
)
|
|
||||||
override val plugin: Backend = SafBackend(context, safProperties, ".SeedvaultTest")
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test write list read rename delete`(): Unit = runBlocking {
|
fun `test write list read rename delete`(): Unit = runBlocking {
|
||||||
|
|
|
@ -24,6 +24,7 @@ import kotlinx.coroutines.withTimeout
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
internal interface LargeBackupTestBase : LargeTestBase {
|
internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
|
|
||||||
|
@ -74,7 +75,6 @@ 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)
|
||||||
|
@ -111,7 +111,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
var data = mutableMapOf<String, ByteArray>()
|
var data = mutableMapOf<String, ByteArray>()
|
||||||
|
|
||||||
coEvery {
|
coEvery {
|
||||||
spyKVBackup.performBackup(any(), any(), any(), any(), any())
|
spyKVBackup.performBackup(any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
packageName = firstArg<PackageInfo>().packageName
|
packageName = firstArg<PackageInfo>().packageName
|
||||||
callOriginal()
|
callOriginal()
|
||||||
|
@ -157,7 +157,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
var dataIntercept = ByteArrayOutputStream()
|
var dataIntercept = ByteArrayOutputStream()
|
||||||
|
|
||||||
coEvery {
|
coEvery {
|
||||||
spyFullBackup.performFullBackup(any(), any(), any(), any(), any())
|
spyFullBackup.performFullBackup(any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
packageName = firstArg<PackageInfo>().packageName
|
packageName = firstArg<PackageInfo>().packageName
|
||||||
callOriginal()
|
callOriginal()
|
||||||
|
@ -172,7 +172,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
every {
|
coEvery {
|
||||||
spyFullBackup.finishBackup()
|
spyFullBackup.finishBackup()
|
||||||
} answers {
|
} answers {
|
||||||
val result = callOriginal()
|
val result = callOriginal()
|
||||||
|
@ -190,7 +190,7 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
clearMocks(spyBackupNotificationManager)
|
clearMocks(spyBackupNotificationManager)
|
||||||
|
|
||||||
every {
|
every {
|
||||||
spyBackupNotificationManager.onBackupFinished(any(), any(), any(), any())
|
spyBackupNotificationManager.onBackupSuccess(any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
val success = firstArg<Boolean>()
|
val success = firstArg<Boolean>()
|
||||||
assert(success) { "Backup failed." }
|
assert(success) { "Backup failed." }
|
||||||
|
@ -198,6 +198,13 @@ internal interface LargeBackupTestBase : LargeTestBase {
|
||||||
callOriginal()
|
callOriginal()
|
||||||
completed.set(true)
|
completed.set(true)
|
||||||
}
|
}
|
||||||
|
every {
|
||||||
|
spyBackupNotificationManager.onBackupError()
|
||||||
|
} answers {
|
||||||
|
callOriginal()
|
||||||
|
completed.set(true)
|
||||||
|
fail("Backup failed.")
|
||||||
|
}
|
||||||
|
|
||||||
return completed
|
return completed
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,7 +63,6 @@ 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)
|
||||||
|
@ -97,7 +96,6 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
||||||
|
|
||||||
return result.copy(
|
return result.copy(
|
||||||
userApps = packageService.userApps,
|
userApps = packageService.userApps,
|
||||||
userNotAllowedApps = packageService.userNotAllowedApps
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,7 +162,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
||||||
clearMocks(spyKVRestore)
|
clearMocks(spyKVRestore)
|
||||||
|
|
||||||
coEvery {
|
coEvery {
|
||||||
spyKVRestore.initializeState(any(), any(), any(), any(), any())
|
spyKVRestore.initializeState(any(), any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
packageName = arg<PackageInfo>(3).packageName
|
packageName = arg<PackageInfo>(3).packageName
|
||||||
restoreResult.kv[packageName!!] = mutableMapOf()
|
restoreResult.kv[packageName!!] = mutableMapOf()
|
||||||
|
@ -189,7 +187,7 @@ internal interface LargeRestoreTestBase : LargeTestBase {
|
||||||
clearMocks(spyFullRestore)
|
clearMocks(spyFullRestore)
|
||||||
|
|
||||||
coEvery {
|
coEvery {
|
||||||
spyFullRestore.initializeState(any(), any(), any(), any())
|
spyFullRestore.initializeState(any(), any(), any())
|
||||||
} answers {
|
} answers {
|
||||||
packageName?.let {
|
packageName?.let {
|
||||||
restoreResult.full[it] = dataIntercept.toByteArray().sha256()
|
restoreResult.full[it] = dataIntercept.toByteArray().sha256()
|
||||||
|
|
|
@ -85,7 +85,6 @@ 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)
|
||||||
|
@ -113,11 +112,9 @@ 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}_${d2d}_${testName.replace(" ", "_")}"
|
return "${timeStamp}_${testName.replace(" ", "_")}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
|
|
|
@ -7,7 +7,6 @@ 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
|
||||||
|
@ -52,17 +51,6 @@ internal abstract class SeedvaultLargeTest :
|
||||||
|
|
||||||
startRecordingTest(keepRecordingScreen, name.methodName)
|
startRecordingTest(keepRecordingScreen, name.methodName)
|
||||||
restoreBaselineBackup()
|
restoreBaselineBackup()
|
||||||
|
|
||||||
val arguments = InstrumentationRegistry.getArguments()
|
|
||||||
|
|
||||||
if (arguments.getString("d2d_backup_test") == "true") {
|
|
||||||
println("Enabling D2D backups for test")
|
|
||||||
settingsManager.setD2dBackupsEnabled(true)
|
|
||||||
|
|
||||||
} else {
|
|
||||||
println("Disabling D2D backups for test")
|
|
||||||
settingsManager.setD2dBackupsEnabled(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|
|
@ -24,7 +24,6 @@ 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 + userNotAllowedApps
|
fun allUserApps() = userApps
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
/*
|
||||||
|
* 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -101,7 +101,9 @@
|
||||||
<activity
|
<activity
|
||||||
android:name=".settings.SettingsActivity"
|
android:name=".settings.SettingsActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS" />
|
android:launchMode="singleTask"
|
||||||
|
android:permission="com.stevesoltys.seedvault.OPEN_SETTINGS"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.storage.StorageActivity"
|
android:name=".ui.storage.StorageActivity"
|
||||||
|
@ -114,12 +116,14 @@
|
||||||
|
|
||||||
<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" />
|
||||||
|
|
23
app/src/main/assets/logback.xml
Normal file
23
app/src/main/assets/logback.xml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
SPDX-License-Identifier: Apache-2.0
|
||||||
|
-->
|
||||||
|
<configuration
|
||||||
|
xmlns="https://tony19.github.io/logback-android/xml"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="https://tony19.github.io/logback-android/xml https://cdn.jsdelivr.net/gh/tony19/logback-android/logback.xsd"
|
||||||
|
>
|
||||||
|
<appender name="logcat" class="ch.qos.logback.classic.android.LogcatAppender">
|
||||||
|
<tagEncoder>
|
||||||
|
<pattern>%logger{12}</pattern>
|
||||||
|
</tagEncoder>
|
||||||
|
<encoder>
|
||||||
|
<pattern>[%-20thread] %msg</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<root level="DEBUG">
|
||||||
|
<appender-ref ref="logcat" />
|
||||||
|
</root>
|
||||||
|
</configuration>
|
||||||
|
|
|
@ -6,6 +6,8 @@
|
||||||
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
|
||||||
|
@ -17,16 +19,18 @@ 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.BackendManager
|
||||||
import com.stevesoltys.seedvault.backend.saf.storagePluginModuleSaf
|
import com.stevesoltys.seedvault.backend.saf.storagePluginModuleSaf
|
||||||
import com.stevesoltys.seedvault.backend.webdav.storagePluginModuleWebDav
|
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.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
|
||||||
|
@ -62,7 +66,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()) }
|
single { BackendManager(this@App, get(), get(), get()) }
|
||||||
single {
|
single {
|
||||||
BackendFactory {
|
BackendFactory {
|
||||||
// uses context of the device's main user to be able to access USB storage
|
// uses context of the device's main user to be able to access USB storage
|
||||||
|
@ -82,15 +86,15 @@ open class App : Application() {
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
keyManager = get(),
|
keyManager = get(),
|
||||||
backendManager = get(),
|
backendManager = get(),
|
||||||
metadataManager = get(),
|
|
||||||
appListRetriever = get(),
|
appListRetriever = get(),
|
||||||
storageBackup = get(),
|
storageBackup = get(),
|
||||||
backupManager = get(),
|
backupManager = get(),
|
||||||
backupInitializer = get(),
|
|
||||||
backupStateManager = get(),
|
backupStateManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
|
viewModel {
|
||||||
|
RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get(), get())
|
||||||
|
}
|
||||||
viewModel {
|
viewModel {
|
||||||
BackupStorageViewModel(
|
BackupStorageViewModel(
|
||||||
app = this@App,
|
app = this@App,
|
||||||
|
@ -110,6 +114,7 @@ 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()
|
||||||
|
@ -125,10 +130,6 @@ open class App : Application() {
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
permitDiskReads {
|
|
||||||
migrateTokenFromMetadataToSettingsManager()
|
|
||||||
}
|
|
||||||
if (!isTest) migrateToOwnScheduling()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun startKoin() = startKoin {
|
protected open fun startKoin() = startKoin {
|
||||||
|
@ -147,29 +148,24 @@ 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 backendManager: BackendManager by inject()
|
||||||
private val backupStateManager: BackupStateManager by inject()
|
private val backupStateManager: BackupStateManager by inject()
|
||||||
|
|
||||||
/**
|
override fun onTrimMemory(level: Int) {
|
||||||
* The responsibility for the current token was moved to the [SettingsManager]
|
Log.w("Seedvault", "onTrimMemory($level) ${getMemStr()}")
|
||||||
* in the end of 2020.
|
val processInfo = RunningAppProcessInfo()
|
||||||
* This method migrates the token for existing installs and can be removed
|
ActivityManager.getMyMemoryState(processInfo)
|
||||||
* after sufficient time has passed.
|
Log.w("Seedvault", " lastTrimLevel: ${processInfo.lastTrimLevel}")
|
||||||
*/
|
Log.w("Seedvault", " importance: ${processInfo.importance}")
|
||||||
private fun migrateTokenFromMetadataToSettingsManager() {
|
super.onTrimMemory(level)
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
val token = metadataManager.getBackupToken()
|
|
||||||
if (token != 0L && settingsManager.getToken() == null) {
|
|
||||||
settingsManager.setNewToken(token)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -200,6 +196,7 @@ 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"
|
||||||
|
|
|
@ -17,18 +17,25 @@ import android.util.Log.DEBUG
|
||||||
|
|
||||||
private val TAG = BackupMonitor::class.java.name
|
private val TAG = BackupMonitor::class.java.name
|
||||||
|
|
||||||
class BackupMonitor : IBackupManagerMonitor.Stub() {
|
open class BackupMonitor : IBackupManagerMonitor.Stub() {
|
||||||
|
|
||||||
override fun onEvent(bundle: Bundle) {
|
override fun onEvent(bundle: Bundle) {
|
||||||
val id = bundle.getInt(EXTRA_LOG_EVENT_ID)
|
onEvent(
|
||||||
val packageName = bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME, "?")
|
id = bundle.getInt(EXTRA_LOG_EVENT_ID),
|
||||||
|
category = bundle.getInt(EXTRA_LOG_EVENT_CATEGORY),
|
||||||
|
packageName = bundle.getString(EXTRA_LOG_EVENT_PACKAGE_NAME),
|
||||||
|
bundle = bundle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun onEvent(id: Int, category: Int, packageName: String?, bundle: Bundle) {
|
||||||
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
|
if (!Log.isLoggable(TAG, DEBUG)) return
|
||||||
Log.d(TAG, "ID: $id")
|
Log.d(TAG, "ID: $id")
|
||||||
Log.d(TAG, "CATEGORY: " + bundle.getInt(EXTRA_LOG_EVENT_CATEGORY, -1))
|
Log.d(TAG, "CATEGORY: $category")
|
||||||
Log.d(TAG, "PACKAGE: $packageName")
|
Log.d(TAG, "PACKAGE: $packageName")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +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 kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
@ -31,14 +32,18 @@ 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, workInfos ->
|
flow4 = workManager.getWorkInfosForUniqueWorkFlow(AppBackupPruneWorker.UNIQUE_WORK_NAME),
|
||||||
val workInfoState = workInfos.getOrNull(0)?.state
|
) { appBackupRunning, filesBackupRunning, workInfo1, workInfo2 ->
|
||||||
|
val workInfoState1 = workInfo1.getOrNull(0)?.state
|
||||||
|
val workInfoState2 = workInfo2.getOrNull(0)?.state
|
||||||
Log.i(
|
Log.i(
|
||||||
TAG, "appBackupRunning: $appBackupRunning, " +
|
TAG, "appBackupRunning: $appBackupRunning, " +
|
||||||
"filesBackupRunning: $filesBackupRunning, " +
|
"filesBackupRunning: $filesBackupRunning, " +
|
||||||
"workInfoState: ${workInfoState?.name}"
|
"appBackupWorker: ${workInfoState1?.name}, " +
|
||||||
|
"pruneBackupWorker: ${workInfoState2?.name}"
|
||||||
)
|
)
|
||||||
appBackupRunning || filesBackupRunning || workInfoState == RUNNING
|
appBackupRunning || filesBackupRunning ||
|
||||||
|
workInfoState1 == RUNNING || workInfoState2 == RUNNING
|
||||||
}
|
}
|
||||||
|
|
||||||
val isAutoRestoreEnabled: Boolean
|
val isAutoRestoreEnabled: Boolean
|
||||||
|
|
24
app/src/main/java/com/stevesoltys/seedvault/MemoryLogger.kt
Normal file
24
app/src/main/java/com/stevesoltys/seedvault/MemoryLogger.kt
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
object MemoryLogger {
|
||||||
|
|
||||||
|
fun log() {
|
||||||
|
Log.d("MemoryLogger", getMemStr())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMemStr(): String {
|
||||||
|
val r = Runtime.getRuntime()
|
||||||
|
val total = r.totalMemory() / 1024 / 1024
|
||||||
|
val free = r.freeMemory() / 1024 / 1024
|
||||||
|
val max = r.maxMemory() / 1024 / 1024
|
||||||
|
val used = total - free
|
||||||
|
return "$free MiB free - $used of $total (max $max)"
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,7 +20,6 @@ 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 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.ui.storage.AUTHORITY_STORAGE
|
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
|
||||||
|
@ -34,7 +33,6 @@ 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 {
|
||||||
|
@ -44,14 +42,15 @@ 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 backupMillis = System.currentTimeMillis() - metadataManager.getLastBackupTime()
|
val lastBackupTime = settingsManager.lastBackupTime.value ?: 0
|
||||||
|
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(metadataManager.getLastBackupTime())}")
|
Log.d(TAG, " ${Date(lastBackupTime)}")
|
||||||
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(metadataManager.getLastBackupTime())}")
|
Log.d(TAG, " ${Date(lastBackupTime)}")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.backend
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import at.bitfire.dav4jvm.exception.HttpException
|
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
suspend fun Backend.getMetadataOutputStream(token: Long): OutputStream {
|
|
||||||
return save(LegacyAppBackupFile.Metadata(token))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun Backend.getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
|
||||||
return try {
|
|
||||||
// get all restore set tokens in root folder that have a metadata file
|
|
||||||
val handles = ArrayList<LegacyAppBackupFile.Metadata>()
|
|
||||||
list(null, LegacyAppBackupFile.Metadata::class) { fileInfo ->
|
|
||||||
val handle = fileInfo.fileHandle as LegacyAppBackupFile.Metadata
|
|
||||||
handles.add(handle)
|
|
||||||
}
|
|
||||||
val handleIterator = handles.iterator()
|
|
||||||
return generateSequence {
|
|
||||||
if (!handleIterator.hasNext()) return@generateSequence null // end sequence
|
|
||||||
val handle = handleIterator.next()
|
|
||||||
EncryptedMetadata(handle.token) {
|
|
||||||
load(handle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("SafBackend", "Error getting available backups: ", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Exception.isOutOfSpace(): Boolean {
|
|
||||||
return when (this) {
|
|
||||||
is IOException -> message?.contains("No space left on device") == true ||
|
|
||||||
(cause as? HttpException)?.code == 507
|
|
||||||
|
|
||||||
is HttpException -> code == 507
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class EncryptedMetadata(val token: Long, val inputStreamRetriever: suspend () -> InputStream)
|
|
|
@ -10,6 +10,7 @@ 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.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.Backend
|
||||||
|
@ -20,6 +21,7 @@ import org.calyxos.seedvault.core.backends.saf.SafBackend
|
||||||
class BackendManager(
|
class BackendManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
|
private val blobCache: BlobCache,
|
||||||
backendFactory: BackendFactory,
|
backendFactory: BackendFactory,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -86,6 +88,8 @@ class BackendManager(
|
||||||
settingsManager.setStorageBackend(backend)
|
settingsManager.setStorageBackend(backend)
|
||||||
mBackend = backend
|
mBackend = backend
|
||||||
mBackendProperties = storageProperties
|
mBackendProperties = storageProperties
|
||||||
|
blobCache.clearLocalCache()
|
||||||
|
// TODO not critical, but nice to have: clear also local snapshot cache
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -29,7 +29,6 @@ import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||||
import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile
|
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
|
||||||
|
|
||||||
@Deprecated("")
|
@Deprecated("")
|
||||||
|
@ -51,7 +50,7 @@ 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
|
||||||
|
|
||||||
internal var rootBackupDir: DocumentFile? = null
|
private var rootBackupDir: DocumentFile? = null
|
||||||
get() = runBlocking {
|
get() = runBlocking {
|
||||||
if (field == null) {
|
if (field == null) {
|
||||||
val parent = safStorage.getDocumentFile(context)
|
val parent = safStorage.getDocumentFile(context)
|
||||||
|
@ -94,16 +93,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -192,7 +181,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<Cursor> { cont ->
|
suspendCancellableCoroutine { 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)
|
||||||
|
|
|
@ -15,7 +15,6 @@ 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.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.backend.getAvailableBackups
|
|
||||||
import com.stevesoltys.seedvault.isMassStorage
|
import com.stevesoltys.seedvault.isMassStorage
|
||||||
import com.stevesoltys.seedvault.settings.FlashDrive
|
import com.stevesoltys.seedvault.settings.FlashDrive
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
@ -59,9 +58,8 @@ internal class SafHandler(
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
suspend fun hasAppBackup(safProperties: SafProperties): Boolean {
|
suspend fun hasAppBackup(safProperties: SafProperties): Boolean {
|
||||||
val appPlugin = backendFactory.createSafBackend(safProperties)
|
val backend = backendFactory.createSafBackend(safProperties)
|
||||||
val backups = appPlugin.getAvailableBackups()
|
return backend.getAvailableBackupFileHandles().isNotEmpty()
|
||||||
return backups != null && backups.iterator().hasNext()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(safProperties: SafProperties) {
|
fun save(safProperties: SafProperties) {
|
||||||
|
|
|
@ -139,17 +139,6 @@ 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 -> {
|
||||||
|
|
|
@ -6,15 +6,14 @@
|
||||||
package com.stevesoltys.seedvault.backend.webdav
|
package com.stevesoltys.seedvault.backend.webdav
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
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.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.backend.getAvailableBackups
|
|
||||||
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 okhttp3.HttpUrl.Companion.toHttpUrl
|
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.BackendFactory
|
import org.calyxos.seedvault.core.backends.BackendFactory
|
||||||
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
|
@ -43,7 +42,7 @@ internal class WebDavHandler(
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties {
|
fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties {
|
||||||
val host = config.url.toHttpUrl().host
|
val host = Uri.parse(config.url).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),
|
||||||
|
@ -81,8 +80,7 @@ internal class WebDavHandler(
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
suspend fun hasAppBackup(backend: Backend): Boolean {
|
suspend fun hasAppBackup(backend: Backend): Boolean {
|
||||||
val backups = backend.getAvailableBackups()
|
return backend.getAvailableBackupFileHandles().isNotEmpty()
|
||||||
return backups != null && backups.iterator().hasNext()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(properties: WebDavProperties) {
|
fun save(properties: WebDavProperties) {
|
||||||
|
|
|
@ -5,23 +5,33 @@
|
||||||
|
|
||||||
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.backup.storage.crypto.StreamCrypto
|
import org.calyxos.seedvault.core.crypto.CoreCrypto
|
||||||
import org.calyxos.backup.storage.crypto.StreamCrypto.deriveStreamKey
|
import org.calyxos.seedvault.core.crypto.CoreCrypto.ALGORITHM_HMAC
|
||||||
|
import org.calyxos.seedvault.core.crypto.CoreCrypto.deriveKey
|
||||||
|
import org.calyxos.seedvault.core.toByteArrayFromHex
|
||||||
|
import org.calyxos.seedvault.core.toHexString
|
||||||
import 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -47,13 +57,18 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the name that identifies an APK in the backup storage plugin.
|
* A secret key of size [KEY_SIZE_BYTES]
|
||||||
* @param suffix empty string for normal APKs and the name of the split in case of an APK split
|
* only used to create a gear table specific to each main key.
|
||||||
*/
|
*/
|
||||||
fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String
|
val gearTableKey: ByteArray
|
||||||
|
|
||||||
|
fun sha256(bytes: ByteArray): ByteArray
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a [AesGcmHkdfStreaming] encrypting stream
|
* Returns a [AesGcmHkdfStreaming] encrypting stream
|
||||||
|
@ -75,6 +90,29 @@ 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
|
||||||
|
@ -122,30 +160,71 @@ 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 key: ByteArray by lazy {
|
private val keyV1: ByteArray by lazy {
|
||||||
deriveStreamKey(keyManager.getMainKey(), "app data key".toByteArray())
|
deriveKey(keyManager.getMainKey(), "app data key".toByteArray())
|
||||||
}
|
}
|
||||||
private val secureRandom: SecureRandom by lazy { SecureRandom() }
|
private val streamKey: ByteArray by lazy {
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sha256(bytes: ByteArray): ByteArray {
|
override 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) {
|
||||||
|
@ -155,21 +234,12 @@ 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 newEncryptingStream(
|
override fun newDecryptingStreamV1(
|
||||||
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 {
|
): InputStream = CoreCrypto.newDecryptingStream(keyV1, inputStream, associatedData)
|
||||||
return StreamCrypto.newDecryptingStream(key, inputStream, associatedData)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
@Throws(IOException::class, SecurityException::class)
|
@Throws(IOException::class, SecurityException::class)
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
@ -20,5 +21,5 @@ val cryptoModule = module {
|
||||||
}
|
}
|
||||||
KeyManagerImpl(keyStore)
|
KeyManagerImpl(keyStore)
|
||||||
}
|
}
|
||||||
single<Crypto> { CryptoImpl(get(), get(), get()) }
|
single<Crypto> { CryptoImpl(androidContext(), get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.extensions
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
|
|
||||||
fun Activity.setupEdgeToEdge() {
|
|
||||||
val rootView = window.decorView.rootView
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, windowInsets ->
|
|
||||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
v.setPadding(insets.left, insets.top, insets.right, insets.bottom)
|
|
||||||
WindowInsetsCompat.CONSUMED
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 = 1
|
internal const val VERSION: Byte = 2
|
||||||
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
|
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
|
||||||
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
|
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
|
||||||
internal const val MAX_VERSION_HEADER_SIZE =
|
internal const val MAX_VERSION_HEADER_SIZE =
|
||||||
|
|
|
@ -8,8 +8,12 @@ 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
|
||||||
|
|
||||||
|
@ -26,6 +30,23 @@ 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)
|
||||||
|
@ -91,12 +112,56 @@ 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 && sha256 != null && signatures != null
|
return version != null && // v2 doesn't use sha256 here
|
||||||
|
(sha256 != null || baseApkChunkIds?.isNotEmpty() == true) &&
|
||||||
|
signatures != null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,6 +169,7 @@ data class ApkSplit(
|
||||||
val name: String,
|
val name: String,
|
||||||
val size: Long?,
|
val size: Long?,
|
||||||
val sha256: String,
|
val sha256: String,
|
||||||
|
val chunkIds: List<String>? = null, // used for v2
|
||||||
// There's also a revisionCode, but it doesn't seem to be used just yet
|
// There's also a revisionCode, but it doesn't seem to be used just yet
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -8,23 +8,11 @@ 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
|
||||||
|
@ -39,130 +27,50 @@ 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 = 0L, salt = "")
|
private val uninitializedMetadata = BackupMetadata(token = -42L, salt = "foo bar")
|
||||||
private var metadata: BackupMetadata = uninitializedMetadata
|
private var metadata: BackupMetadata = uninitializedMetadata
|
||||||
get() {
|
get() {
|
||||||
if (field == uninitializedMetadata) {
|
if (field == uninitializedMetadata) {
|
||||||
field = try {
|
field = try {
|
||||||
getMetadataFromCache() ?: throw IOException()
|
val m = 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)
|
||||||
// This should cause requiresInit() return true
|
uninitializedMetadata.copy(salt = "initialized")
|
||||||
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
|
||||||
modifyMetadata(metadataOutputStream) {
|
modifyCachedMetadata {
|
||||||
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
|
||||||
|
@ -170,10 +78,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -189,21 +93,16 @@ 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." }
|
||||||
modifyMetadata(metadataOutputStream) {
|
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,
|
||||||
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
|
||||||
}
|
}
|
||||||
|
@ -213,7 +112,6 @@ 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)
|
||||||
|
@ -222,14 +120,10 @@ 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
|
||||||
|
@ -240,86 +134,26 @@ internal class MetadataManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploads metadata to given [metadataOutputStream] after performing local modifications.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
@Throws(IOException::class)
|
|
||||||
fun uploadMetadata(metadataOutputStream: OutputStream) {
|
|
||||||
metadataWriter.write(metadata, metadataOutputStream)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private fun modifyCachedMetadata(modFun: () -> Unit) {
|
|
||||||
val oldMetadata = metadata.copy( // copy map, otherwise it will re-use same reference
|
|
||||||
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
modFun.invoke()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
@Synchronized
|
||||||
fun getPackageMetadata(packageName: String): PackageMetadata? {
|
fun getPackageMetadata(packageName: String): PackageMetadata? {
|
||||||
return metadata.packageMetadataMap[packageName]?.copy()
|
return metadata.packageMetadataMap[packageName]?.copy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Throws(IOException::class)
|
||||||
fun getPackagesBackupSize(): Long {
|
private fun modifyCachedMetadata(modFun: () -> Unit) {
|
||||||
return metadata.packageMetadataMap.values.sumOf { it.size ?: 0L }
|
val oldMetadata = metadata.copy(
|
||||||
|
// copy map, otherwise it will re-use same reference
|
||||||
|
packageMetadataMap = PackageMetadataMap(metadata.packageMetadataMap),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
modFun.invoke()
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
@ -347,12 +181,4 @@ internal class MetadataManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getUserName(): String? {
|
|
||||||
val perm = "android.permission.QUERY_USERS"
|
|
||||||
return if (context.checkSelfPermission(perm) == PERMISSION_GRANTED) {
|
|
||||||
val userManager = context.getSystemService(UserManager::class.java) ?: return null
|
|
||||||
userManager.userName
|
|
||||||
} else null
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val metadataModule = module {
|
val metadataModule = module {
|
||||||
single { MetadataManager(androidContext(), get(), get(), get(), get(), get(), get()) }
|
single { MetadataManager(androidContext(), get(), get(), get()) }
|
||||||
single<MetadataWriter> { MetadataWriterImpl(get()) }
|
single<MetadataWriter> { MetadataWriterImpl() }
|
||||||
single<MetadataReader> { MetadataReaderImpl(get()) }
|
single<MetadataReader> { MetadataReaderImpl(get()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
||||||
if (version == 0.toByte()) return readMetadataV0(inputStream, expectedToken)
|
if (version == 0.toByte()) return readMetadataV0(inputStream, expectedToken)
|
||||||
|
|
||||||
val metadataBytes = try {
|
val metadataBytes = try {
|
||||||
crypto.newDecryptingStream(inputStream, getAD(version, expectedToken)).readBytes()
|
crypto.newDecryptingStreamV1(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.getInt(JSON_METADATA_VERSION).toByte()
|
val version = meta.optInt(JSON_METADATA_VERSION, VERSION.toInt()).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.getLong(JSON_METADATA_TOKEN)
|
val token = meta.optLong(JSON_METADATA_TOKEN, 0)
|
||||||
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.getString(JSON_METADATA_SALT),
|
salt = if (version == 0.toByte()) "" else meta.optString(JSON_METADATA_SALT, ""),
|
||||||
time = meta.getLong(JSON_METADATA_TIME),
|
time = meta.optLong(JSON_METADATA_TIME, -1),
|
||||||
androidVersion = meta.getInt(JSON_METADATA_SDK_INT),
|
androidVersion = meta.optInt(JSON_METADATA_SDK_INT, 0),
|
||||||
androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL),
|
androidIncremental = meta.optString(JSON_METADATA_INCREMENTAL),
|
||||||
deviceName = meta.getString(JSON_METADATA_NAME),
|
deviceName = meta.optString(JSON_METADATA_NAME),
|
||||||
d2dBackup = meta.optBoolean(JSON_METADATA_D2D_BACKUP, false),
|
d2dBackup = meta.optBoolean(JSON_METADATA_D2D_BACKUP, false),
|
||||||
packageMetadataMap = packageMetadataMap,
|
packageMetadataMap = packageMetadataMap,
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,42 +6,18 @@
|
||||||
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(private val crypto: Crypto) : MetadataWriter {
|
internal class MetadataWriterImpl : 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().apply {
|
put(JSON_METADATA, JSONObject())
|
||||||
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 {
|
||||||
|
@ -57,31 +33,8 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,191 @@
|
||||||
|
/*
|
||||||
|
* 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* 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()
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
/*
|
||||||
|
* 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" }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
166
app/src/main/java/com/stevesoltys/seedvault/repo/BlobCache.kt
Normal file
166
app/src/main/java/com/stevesoltys/seedvault/repo/BlobCache.kt
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
/*
|
||||||
|
* 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 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
|
||||||
|
|
||||||
|
private const val CACHE_FILE_NAME = "blobsCache"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 blobIds = blobs.associate {
|
||||||
|
Pair(it.fileHandle.name, it.size.toInt())
|
||||||
|
}
|
||||||
|
// load local blob cache and include only blobs on backend
|
||||||
|
loadPersistentBlobCache(blobIds)
|
||||||
|
// build up mapping from chunkId to blob from available snapshots
|
||||||
|
snapshots.forEach { snapshot ->
|
||||||
|
onSnapshotLoaded(snapshot, blobIds)
|
||||||
|
}
|
||||||
|
MemoryLogger.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should only be called after [populateCache] has returned.
|
||||||
|
*/
|
||||||
|
operator fun get(chunkId: String): Blob? = blobMap[chunkId]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should get called for all new blobs as soon as they've been saved to the backend.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
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 [blobIds]
|
||||||
|
* and its size matches the one on backend, i.e. value of [blobIds].
|
||||||
|
*/
|
||||||
|
private fun onSnapshotLoaded(snapshot: Snapshot, blobIds: Map<String, Int>) {
|
||||||
|
snapshot.blobsMap.forEach { (chunkId, blob) ->
|
||||||
|
// check if referenced blob still exists on backend
|
||||||
|
val blobId = blob.id.hexFromProto()
|
||||||
|
val sizeOnBackend = blobIds[blobId]
|
||||||
|
if (sizeOnBackend == blob.length) {
|
||||||
|
// only add blob to our mapping, if it still exists
|
||||||
|
blobMap.putIfAbsent(chunkId, blob)?.let { previous ->
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
111
app/src/main/java/com/stevesoltys/seedvault/repo/Loader.kt
Normal file
111
app/src/main/java/com/stevesoltys/seedvault/repo/Loader.kt
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
/*
|
||||||
|
* 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 GeneralSecurityException("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 ciperText 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
28
app/src/main/java/com/stevesoltys/seedvault/repo/Padding.kt
Normal file
28
app/src/main/java/com/stevesoltys/seedvault/repo/Padding.kt
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
122
app/src/main/java/com/stevesoltys/seedvault/repo/Pruner.kt
Normal file
122
app/src/main/java/com/stevesoltys/seedvault/repo/Pruner.kt
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
/*
|
||||||
|
* 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 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()
|
||||||
|
blobHandles.forEach { blobHandle ->
|
||||||
|
if (blobHandle.name !in usedBlobIds) {
|
||||||
|
log.info { "Removing blob ${blobHandle.name}" }
|
||||||
|
backendManager.backend.remove(blobHandle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* 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()) }
|
||||||
|
}
|
|
@ -0,0 +1,221 @@
|
||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
|
@ -0,0 +1,162 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.repo
|
||||||
|
|
||||||
|
import com.github.luben.zstd.ZstdOutputStream
|
||||||
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
|
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
|
import com.stevesoltys.seedvault.proto.Snapshot
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||||
|
import org.calyxos.seedvault.core.backends.Constants.appSnapshotRegex
|
||||||
|
import org.calyxos.seedvault.core.toHexString
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
|
|
||||||
|
internal const val FOLDER_SNAPSHOTS = "snapshots"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages interactions with snapshots, such as loading, saving and removing them.
|
||||||
|
* Also keeps a reference to the [latestSnapshot] that holds important re-usable data.
|
||||||
|
*/
|
||||||
|
internal class SnapshotManager(
|
||||||
|
private val snapshotFolderRoot: File,
|
||||||
|
private val crypto: Crypto,
|
||||||
|
private val loader: Loader,
|
||||||
|
private val backendManager: BackendManager,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val log = KotlinLogging.logger {}
|
||||||
|
private val snapshotFolder: File get() = File(snapshotFolderRoot, crypto.repoId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The latest [Snapshot]. May be stale if [onSnapshotsLoaded] has not returned
|
||||||
|
* or wasn't called since new snapshots have been created.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
var latestSnapshot: Snapshot? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call this before starting a backup run with the [handles] of snapshots
|
||||||
|
* currently available on the backend.
|
||||||
|
*/
|
||||||
|
suspend fun onSnapshotsLoaded(handles: List<AppBackupFileType.Snapshot>): List<Snapshot> {
|
||||||
|
// first reset latest snapshot, otherwise we'd hang on to a stale one
|
||||||
|
// e.g. when switching to new storage without any snapshots
|
||||||
|
latestSnapshot = null
|
||||||
|
return handles.mapNotNull { snapshotHandle ->
|
||||||
|
val snapshot = try {
|
||||||
|
loadSnapshot(snapshotHandle)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// This isn't ideal, but the show must go on and we take the snapshots we can get.
|
||||||
|
// After the first load, a snapshot will get cached, so we are not hitting backend.
|
||||||
|
// TODO use a re-trying backend for snapshot loading
|
||||||
|
log.error(e) { "Error loading snapshot: $snapshotHandle" }
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
// update latest snapshot if this one is more recent
|
||||||
|
if (snapshot.token > (latestSnapshot?.token ?: 0)) latestSnapshot = snapshot
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the given [snapshot] to the backend and local cache.
|
||||||
|
*
|
||||||
|
* @throws IOException or others if saving fails.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun saveSnapshot(snapshot: Snapshot) {
|
||||||
|
// compress payload and get size
|
||||||
|
val payloadStream = ByteArrayOutputStream()
|
||||||
|
ZstdOutputStream(payloadStream).use { zstdOutputStream ->
|
||||||
|
snapshot.writeTo(zstdOutputStream)
|
||||||
|
}
|
||||||
|
val payloadSize = payloadStream.size()
|
||||||
|
val payloadSizeBytes = ByteBuffer.allocate(4).putInt(payloadSize).array()
|
||||||
|
|
||||||
|
// encrypt compressed payload and assemble entire blob
|
||||||
|
val byteStream = ByteArrayOutputStream()
|
||||||
|
byteStream.write(VERSION.toInt())
|
||||||
|
crypto.newEncryptingStream(byteStream, crypto.getAdForVersion()).use { cryptoStream ->
|
||||||
|
cryptoStream.write(payloadSizeBytes)
|
||||||
|
cryptoStream.write(payloadStream.toByteArray())
|
||||||
|
// not adding any padding here, because it doesn't matter for snapshots
|
||||||
|
}
|
||||||
|
payloadStream.reset()
|
||||||
|
val bytes = byteStream.toByteArray()
|
||||||
|
byteStream.reset()
|
||||||
|
|
||||||
|
// compute hash and save blob
|
||||||
|
val sha256 = crypto.sha256(bytes).toHexString()
|
||||||
|
val snapshotHandle = AppBackupFileType.Snapshot(crypto.repoId, sha256)
|
||||||
|
backendManager.backend.save(snapshotHandle).use { outputStream ->
|
||||||
|
outputStream.write(bytes)
|
||||||
|
}
|
||||||
|
// save to local cache while at it
|
||||||
|
try {
|
||||||
|
if (!snapshotFolder.isDirectory) snapshotFolder.mkdirs()
|
||||||
|
File(snapshotFolder, snapshotHandle.name).outputStream().use { outputStream ->
|
||||||
|
outputStream.write(bytes)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { // we'll let this one pass
|
||||||
|
log.error(e) { "Error saving snapshot ${snapshotHandle.hash} to cache: " }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the snapshot referenced by the given [snapshotHandle] from the backend
|
||||||
|
* and local cache.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
suspend fun removeSnapshot(snapshotHandle: AppBackupFileType.Snapshot) {
|
||||||
|
backendManager.backend.remove(snapshotHandle)
|
||||||
|
// remove from cache as well
|
||||||
|
File(snapshotFolder, snapshotHandle.name).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads and parses the snapshot referenced by the given [snapshotHandle].
|
||||||
|
* If a locally cached version exists, the backend will not be hit.
|
||||||
|
*/
|
||||||
|
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||||
|
suspend fun loadSnapshot(snapshotHandle: AppBackupFileType.Snapshot): Snapshot {
|
||||||
|
val file = File(snapshotFolder, snapshotHandle.name)
|
||||||
|
snapshotFolder.mkdirs()
|
||||||
|
val inputStream = if (file.isFile) {
|
||||||
|
try {
|
||||||
|
loader.loadFile(file, snapshotHandle.hash)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error(e) { "Error loading $snapshotHandle from local cache. Trying backend..." }
|
||||||
|
loader.loadFile(snapshotHandle, file)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loader.loadFile(snapshotHandle, file)
|
||||||
|
}
|
||||||
|
return inputStream.use { Snapshot.parseFrom(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||||
|
fun loadCachedSnapshots(): List<Snapshot> {
|
||||||
|
if (!snapshotFolder.isDirectory) return emptyList()
|
||||||
|
return snapshotFolder.listFiles()?.mapNotNull { file ->
|
||||||
|
val match = appSnapshotRegex.matchEntire(file.name)
|
||||||
|
if (match == null) {
|
||||||
|
log.error { "Unexpected file found: $file" }
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
loader.loadFile(file, match.groupValues[1]).use { Snapshot.parseFrom(it) }
|
||||||
|
}
|
||||||
|
} ?: throw IOException("Could not access snapshotFolder")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -23,12 +23,12 @@ import com.stevesoltys.seedvault.BackupMonitor
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState
|
import com.stevesoltys.seedvault.metadata.PackageState
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
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,7 +54,6 @@ 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 backendManager: BackendManager,
|
||||||
) {
|
) {
|
||||||
|
@ -84,12 +83,6 @@ 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()
|
||||||
|
@ -217,7 +210,7 @@ internal class AppDataRestoreManager(
|
||||||
context.stopService(foregroundServiceIntent)
|
context.stopService(foregroundServiceIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun closeSession() {
|
private fun closeSession() {
|
||||||
session?.endRestoreSession()
|
session?.endRestoreSession()
|
||||||
session = null
|
session = null
|
||||||
}
|
}
|
||||||
|
@ -263,20 +256,20 @@ internal class AppDataRestoreManager(
|
||||||
/**
|
/**
|
||||||
* Restore the next chunk of packages.
|
* Restore the next chunk of packages.
|
||||||
*
|
*
|
||||||
* We need to restore in chunks, otherwise [BackupTransport.startRestore] in the
|
* We need to restore packages in chunks, otherwise [BackupTransport.startRestore] in the
|
||||||
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder
|
* framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder transaction,
|
||||||
* transaction, causing the entire restoration to fail.
|
* causing the entire restoration to fail due to too many package names.
|
||||||
*/
|
*/
|
||||||
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.
|
||||||
val backupMetadata = restorableBackup.backupMetadata
|
restoreCoordinator.beforeStartRestore(restorableBackup)
|
||||||
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 = backupMetadata.token
|
val token = restorableBackup.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
|
||||||
|
@ -317,6 +310,7 @@ 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.
|
||||||
|
@ -325,6 +319,7 @@ 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()
|
||||||
|
|
|
@ -12,9 +12,10 @@ 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.backend.BackendManager
|
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||||
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.IconManager
|
import com.stevesoltys.seedvault.worker.IconManager
|
||||||
|
@ -68,31 +69,41 @@ 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)
|
||||||
}
|
}
|
||||||
val systemItem = SelectableAppItem(
|
if (restorableBackup.packageMetadataMap.isNotEmpty()) {
|
||||||
packageName = PACKAGE_NAME_SYSTEM,
|
val systemItem = SelectableAppItem(
|
||||||
metadata = PackageMetadata(
|
packageName = PACKAGE_NAME_SYSTEM,
|
||||||
time = restorableBackup.packageMetadataMap.values.maxOf {
|
metadata = PackageMetadata(
|
||||||
if (it.system) it.time else -1
|
time = restorableBackup.packageMetadataMap.values.maxOf {
|
||||||
},
|
if (it.system) it.time else -1
|
||||||
size = restorableBackup.packageMetadataMap.values.sumOf {
|
},
|
||||||
if (it.system) it.size ?: 0L else 0L
|
size = restorableBackup.packageMetadataMap.values.sumOf {
|
||||||
},
|
if (it.system) it.size ?: 0L else 0L
|
||||||
system = true,
|
},
|
||||||
name = context.getString(R.string.backup_system_apps),
|
system = true,
|
||||||
),
|
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 backend = backendManager.backend
|
|
||||||
val token = restorableBackup.token
|
|
||||||
val packagesWithIcons = try {
|
val packagesWithIcons = try {
|
||||||
backend.load(LegacyAppBackupFile.IconsFile(token)).use {
|
if (restorableBackup.version == 1.toByte()) {
|
||||||
iconManager.downloadIcons(restorableBackup.version, token, it)
|
val backend = backendManager.backend
|
||||||
|
val token = restorableBackup.token
|
||||||
|
backend.load(LegacyAppBackupFile.IconsFile(token)).use {
|
||||||
|
iconManager.downloadIconsV1(token, it)
|
||||||
|
}
|
||||||
|
} else if (restorableBackup.version >= 2) {
|
||||||
|
val repoId = restorableBackup.repoId ?: error("No repoId in v2 backup")
|
||||||
|
val snapshot = restorableBackup.snapshot ?: error("No snapshot in v2 backup")
|
||||||
|
iconManager.downloadIcons(repoId, snapshot)
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error loading icons:", e)
|
Log.e(TAG, "Error loading icons:", e)
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
/*
|
||||||
|
* 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.sharedViewModel
|
||||||
|
|
||||||
|
class RecycleBackupFragment : Fragment() {
|
||||||
|
|
||||||
|
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,40 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2020 The Calyx Institute
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.restore
|
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
|
||||||
|
|
||||||
data class RestorableBackup(val backupMetadata: BackupMetadata) {
|
|
||||||
|
|
||||||
val name: String
|
|
||||||
get() = backupMetadata.deviceName
|
|
||||||
|
|
||||||
val version: Byte
|
|
||||||
get() = backupMetadata.version
|
|
||||||
|
|
||||||
val token: Long
|
|
||||||
get() = backupMetadata.token
|
|
||||||
|
|
||||||
val salt: String
|
|
||||||
get() = backupMetadata.salt
|
|
||||||
|
|
||||||
val time: Long
|
|
||||||
get() = backupMetadata.time
|
|
||||||
|
|
||||||
val size: Long?
|
|
||||||
get() = backupMetadata.size
|
|
||||||
|
|
||||||
val deviceName: String
|
|
||||||
get() = backupMetadata.deviceName
|
|
||||||
|
|
||||||
val d2dBackup: Boolean
|
|
||||||
get() = backupMetadata.d2dBackup
|
|
||||||
|
|
||||||
val packageMetadataMap: PackageMetadataMap
|
|
||||||
get() = backupMetadata.packageMetadataMap
|
|
||||||
|
|
||||||
}
|
|
|
@ -8,6 +8,7 @@ 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
|
||||||
|
@ -35,6 +36,7 @@ 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 -> {
|
||||||
|
|
|
@ -39,8 +39,8 @@ class RestoreService : Service() {
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.i(TAG, "onDestroy")
|
Log.i(TAG, "onDestroy")
|
||||||
super.onDestroy()
|
|
||||||
nm.cancelRestoreNotification()
|
nm.cancelRestoreNotification()
|
||||||
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,9 +6,9 @@
|
||||||
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.HOUR_IN_MILLIS
|
import android.text.format.DateUtils.MINUTE_IN_MILLIS
|
||||||
import android.text.format.DateUtils.getRelativeTimeSpanString
|
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||||
import android.text.format.Formatter
|
import android.text.format.Formatter.formatShortFileSize
|
||||||
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
|
||||||
|
@ -19,6 +19,7 @@ import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||||
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
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,
|
||||||
|
@ -40,32 +41,40 @@ internal class RestoreSetAdapter(
|
||||||
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
|
inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
|
||||||
|
|
||||||
private val titleView = v.requireViewById<TextView>(R.id.titleView)
|
private val titleView = v.requireViewById<TextView>(R.id.titleView)
|
||||||
private val subtitleView = v.requireViewById<TextView>(R.id.subtitleView)
|
private val appView = v.requireViewById<TextView>(R.id.appView)
|
||||||
private val sizeView = v.requireViewById<TextView>(R.id.sizeView)
|
private val apkView = v.requireViewById<TextView>(R.id.apkView)
|
||||||
|
private val timeView = v.requireViewById<TextView>(R.id.timeView)
|
||||||
|
|
||||||
internal fun bind(item: RestorableBackup) {
|
internal fun bind(item: RestorableBackup) {
|
||||||
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
|
v.setOnClickListener { listener.onRestorableBackupClicked(item) }
|
||||||
titleView.text = item.name
|
titleView.text = item.name
|
||||||
|
|
||||||
val lastBackup = getRelativeTime(item.time)
|
appView.text = if (item.sizeAppData > 0) {
|
||||||
val setup = getRelativeTime(item.token)
|
v.context.getString(
|
||||||
subtitleView.text =
|
R.string.restore_restore_set_apps,
|
||||||
v.context.getString(R.string.restore_restore_set_times, lastBackup, setup)
|
item.numAppData,
|
||||||
val size = item.size
|
formatShortFileSize(v.context, item.sizeAppData),
|
||||||
if (size == null) {
|
|
||||||
sizeView.visibility = GONE
|
|
||||||
} else {
|
|
||||||
sizeView.text = v.context.getString(
|
|
||||||
R.string.restore_restore_set_size,
|
|
||||||
Formatter.formatShortFileSize(v.context, size),
|
|
||||||
)
|
)
|
||||||
sizeView.visibility = VISIBLE
|
} else {
|
||||||
|
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.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) VISIBLE else GONE
|
||||||
|
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, HOUR_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
|
return getRelativeTimeSpanString(time, now, MINUTE_IN_MILLIS, FORMAT_ABBREV_RELATIVE)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ 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.sharedViewModel
|
||||||
|
|
||||||
class RestoreSetFragment : Fragment() {
|
class RestoreSetFragment : Fragment() {
|
||||||
|
|
|
@ -19,6 +19,7 @@ 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(),
|
||||||
|
|
|
@ -17,8 +17,10 @@ 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.crypto.KeyManager
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||||
|
import com.stevesoltys.seedvault.restore.DisplayFragment.RECYCLE_BACKUP
|
||||||
import com.stevesoltys.seedvault.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
|
||||||
|
@ -30,6 +32,9 @@ 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
|
||||||
|
@ -62,6 +67,7 @@ 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,
|
||||||
|
@ -77,7 +83,7 @@ internal class RestoreViewModel(
|
||||||
private val appSelectionManager =
|
private val appSelectionManager =
|
||||||
AppSelectionManager(app, backendManager, iconManager, viewModelScope)
|
AppSelectionManager(app, backendManager, iconManager, viewModelScope)
|
||||||
private val appDataRestoreManager = AppDataRestoreManager(
|
private val appDataRestoreManager = AppDataRestoreManager(
|
||||||
app, backupManager, settingsManager, restoreCoordinator, backendManager
|
app, backupManager, restoreCoordinator, backendManager
|
||||||
)
|
)
|
||||||
|
|
||||||
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
|
private val mDisplayFragment = MutableLiveEvent<DisplayFragment>()
|
||||||
|
@ -106,20 +112,11 @@ 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 backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->
|
val result = when (val backups = restoreCoordinator.getAvailableBackups()) {
|
||||||
when (metadata.time) {
|
is ErrorResult -> RestoreSetResult(
|
||||||
0L -> {
|
app.getString(R.string.restore_set_error) + "\n\n${backups.e}"
|
||||||
Log.d(TAG, "Ignoring RestoreSet with no last backup time: $token.")
|
)
|
||||||
null
|
is SuccessResult -> RestoreSetResult(backups.backups)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
@ -176,11 +173,28 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -225,6 +239,7 @@ internal enum class DisplayFragment {
|
||||||
SELECT_APPS,
|
SELECT_APPS,
|
||||||
RESTORE_APPS,
|
RESTORE_APPS,
|
||||||
RESTORE_BACKUP,
|
RESTORE_BACKUP,
|
||||||
|
RECYCLE_BACKUP,
|
||||||
RESTORE_FILES,
|
RESTORE_FILES,
|
||||||
RESTORE_SELECT_FILES,
|
RESTORE_SELECT_FILES,
|
||||||
RESTORE_FILES_STARTED,
|
RESTORE_FILES_STARTED,
|
||||||
|
|
|
@ -11,15 +11,19 @@ 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.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
|
import com.stevesoltys.seedvault.encodeBase64
|
||||||
|
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
|
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
|
import com.stevesoltys.seedvault.repo.Loader
|
||||||
|
import com.stevesoltys.seedvault.repo.getBlobHandles
|
||||||
import com.stevesoltys.seedvault.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
|
||||||
|
@ -27,8 +31,8 @@ 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.worker.copyStreamsAndGetHash
|
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||||
import com.stevesoltys.seedvault.worker.getSignatures
|
import com.stevesoltys.seedvault.worker.hashSignature
|
||||||
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
|
||||||
|
@ -37,6 +41,10 @@ import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
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
|
||||||
|
@ -46,6 +54,7 @@ internal class ApkRestore(
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
private val backupStateManager: BackupStateManager,
|
private val backupStateManager: BackupStateManager,
|
||||||
private val backendManager: BackendManager,
|
private val backendManager: BackendManager,
|
||||||
|
private val loader: Loader,
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin,
|
private val legacyStoragePlugin: LegacyStoragePlugin,
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
|
@ -130,6 +139,7 @@ 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) }
|
||||||
}
|
}
|
||||||
|
@ -154,7 +164,12 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ThrowsCount")
|
@Suppress("ThrowsCount")
|
||||||
@Throws(IOException::class, SecurityException::class)
|
@Throws(
|
||||||
|
GeneralSecurityException::class,
|
||||||
|
UnsupportedVersionException::class,
|
||||||
|
IOException::class,
|
||||||
|
SecurityException::class,
|
||||||
|
)
|
||||||
private suspend fun restore(
|
private suspend fun restore(
|
||||||
backup: RestorableBackup,
|
backup: RestorableBackup,
|
||||||
packageName: String,
|
packageName: String,
|
||||||
|
@ -168,10 +183,10 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
|
|
||||||
// cache the APK and get its hash
|
// cache the APK and get its hash
|
||||||
val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
|
val (cachedApk, sha256) = cacheApk(backup, packageName, metadata.baseApkChunkIds)
|
||||||
|
|
||||||
// check APK's SHA-256 hash
|
// check APK's SHA-256 hash for backup versions before 2
|
||||||
if (metadata.sha256 != sha256) throw SecurityException(
|
if (backup.version < 2 && metadata.sha256 != sha256) throw SecurityException(
|
||||||
"Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected."
|
"Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -262,10 +277,9 @@ 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 salt = backup.salt
|
val (file, sha256) = cacheApk(backup, packageName, apkSplit.chunkIds, suffix)
|
||||||
val (file, sha256) = cacheApk(backup.version, backup.token, salt, packageName, suffix)
|
// check APK split's SHA-256 hash for backup versions before 2
|
||||||
// check APK split's SHA-256 hash
|
if (backup.version < 2 && apkSplit.sha256 != sha256) throw SecurityException(
|
||||||
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."
|
||||||
)
|
)
|
||||||
|
@ -280,22 +294,32 @@ internal class ApkRestore(
|
||||||
*
|
*
|
||||||
* @return a [Pair] of the cached [File] and SHA-256 hash.
|
* @return a [Pair] of the cached [File] and SHA-256 hash.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(GeneralSecurityException::class, UnsupportedVersionException::class, IOException::class)
|
||||||
private suspend fun cacheApk(
|
private suspend fun cacheApk(
|
||||||
version: Byte,
|
backup: RestorableBackup,
|
||||||
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 = if (version == 0.toByte()) {
|
val inputStream = when (backup.version) {
|
||||||
legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
|
0.toByte() -> {
|
||||||
} else {
|
legacyStoragePlugin.getApkInputStream(backup.token, packageName, suffix)
|
||||||
val name = crypto.getNameForApk(salt, packageName, suffix)
|
}
|
||||||
backend.load(LegacyAppBackupFile.Blob(token, name))
|
1.toByte() -> {
|
||||||
|
val name = crypto.getNameForApk(backup.salt, packageName, suffix)
|
||||||
|
backend.load(LegacyAppBackupFile.Blob(backup.token, name))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val repoId = backup.repoId ?: error("No repoId for v2 backup")
|
||||||
|
val snapshot = backup.snapshot ?: error("No snapshot for v2 backup")
|
||||||
|
val handles = chunkIds?.let {
|
||||||
|
snapshot.getBlobHandles(repoId, it)
|
||||||
|
} ?: error("No chunkIds for $packageName-$suffix")
|
||||||
|
loader.loadFiles(handles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
|
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
|
||||||
return Pair(cachedApk, sha256)
|
return Pair(cachedApk, sha256)
|
||||||
|
@ -343,3 +367,45 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the APK from the given [InputStream] to the given [OutputStream]
|
||||||
|
* and calculate the SHA-256 hash while at it.
|
||||||
|
*
|
||||||
|
* Both streams will be closed when the method returns.
|
||||||
|
*
|
||||||
|
* @return the APK's SHA-256 hash in Base64 format.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun copyStreamsAndGetHash(inputStream: InputStream, outputStream: OutputStream): String {
|
||||||
|
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||||
|
outputStream.use { oStream ->
|
||||||
|
inputStream.use { inputStream ->
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var bytes = inputStream.read(buffer)
|
||||||
|
while (bytes >= 0) {
|
||||||
|
oStream.write(buffer, 0, bytes)
|
||||||
|
messageDigest.update(buffer, 0, bytes)
|
||||||
|
bytes = inputStream.read(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messageDigest.digest().encodeBase64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of Base64 encoded SHA-256 signature hashes.
|
||||||
|
*/
|
||||||
|
fun SigningInfo?.getSignatures(): List<String> {
|
||||||
|
return if (this == null) {
|
||||||
|
emptyList()
|
||||||
|
} else if (hasMultipleSigners()) {
|
||||||
|
apkContentsSigners.map { signature ->
|
||||||
|
hashSignature(signature).encodeBase64()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
signingCertificateHistory.map { signature ->
|
||||||
|
hashSignature(signature).encodeBase64()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ val installModule = module {
|
||||||
factory { DeviceInfo(androidContext()) }
|
factory { DeviceInfo(androidContext()) }
|
||||||
factory { ApkSplitCompatibilityChecker(get()) }
|
factory { ApkSplitCompatibilityChecker(get()) }
|
||||||
factory {
|
factory {
|
||||||
ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get()) {
|
ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) {
|
||||||
androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks()
|
androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,7 +84,7 @@ internal class InstallProgressAdapter(
|
||||||
if (item.icon == null) iconJob = scope.launch {
|
if (item.icon == null) iconJob = scope.launch {
|
||||||
iconLoader(item, appIcon::setImageDrawable)
|
iconLoader(item, appIcon::setImageDrawable)
|
||||||
} else appIcon.setImageDrawable(item.icon)
|
} else appIcon.setImageDrawable(item.icon)
|
||||||
appName.text = item.name ?: getAppName(v.context, item.packageName.toString())
|
appName.text = item.name ?: getAppName(v.context, item.packageName)
|
||||||
appInfo.visibility = GONE
|
appInfo.visibility = GONE
|
||||||
when (item.state) {
|
when (item.state) {
|
||||||
IN_PROGRESS -> {
|
IN_PROGRESS -> {
|
||||||
|
|
|
@ -9,7 +9,6 @@ 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
|
||||||
|
@ -30,8 +29,6 @@ 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(
|
||||||
|
@ -62,7 +59,6 @@ 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) ->
|
||||||
|
@ -81,8 +77,7 @@ internal class AppListRetriever(
|
||||||
AppStatus(
|
AppStatus(
|
||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
enabled = settingsManager.isBackupEnabled(packageName),
|
enabled = settingsManager.isBackupEnabled(packageName),
|
||||||
icon = data.iconRes?.let { getDrawable(context, it) }
|
icon = getDrawable(context, data.iconRes) ?: getIconFromPackageManager(packageName),
|
||||||
?: 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,
|
||||||
|
@ -99,14 +94,11 @@ 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 = getAppName(context, it.packageName).toString(),
|
name = metadata?.name?.toString() ?: getAppName(context, it.packageName).toString(),
|
||||||
time = time,
|
time = time,
|
||||||
size = metadata?.size,
|
size = metadata?.size,
|
||||||
status = status,
|
status = status,
|
||||||
|
@ -121,7 +113,8 @@ internal class AppListRetriever(
|
||||||
packageName = packageName,
|
packageName = packageName,
|
||||||
enabled = settingsManager.isBackupEnabled(packageName),
|
enabled = settingsManager.isBackupEnabled(packageName),
|
||||||
icon = getIconFromPackageManager(packageName),
|
icon = getIconFromPackageManager(packageName),
|
||||||
name = it.loadLabel(context.packageManager).toString(),
|
name = metadata?.name?.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(),
|
||||||
|
@ -129,21 +122,6 @@ 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) {
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.settings
|
package com.stevesoltys.seedvault.settings
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
|
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
|
||||||
|
@ -24,7 +25,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.recyclerview.widget.RecyclerView.Adapter
|
import androidx.recyclerview.widget.RecyclerView.Adapter
|
||||||
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
|
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_NOT_ALLOWED
|
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED_WAS_STOPPED
|
||||||
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED
|
import com.stevesoltys.seedvault.ui.AppBackupState.SUCCEEDED
|
||||||
import com.stevesoltys.seedvault.ui.AppViewHolder
|
import com.stevesoltys.seedvault.ui.AppViewHolder
|
||||||
import com.stevesoltys.seedvault.ui.toRelativeTime
|
import com.stevesoltys.seedvault.ui.toRelativeTime
|
||||||
|
@ -114,13 +115,7 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
|
||||||
startActivity(context, intent, null)
|
startActivity(context, intent, null)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
if (item.status == FAILED_NOT_ALLOWED) {
|
setState(item.status, false)
|
||||||
appStatus.visibility = INVISIBLE
|
|
||||||
progressBar.visibility = INVISIBLE
|
|
||||||
appInfo.visibility = GONE
|
|
||||||
} else {
|
|
||||||
setState(item.status, false)
|
|
||||||
}
|
|
||||||
if (item.status == SUCCEEDED) {
|
if (item.status == SUCCEEDED) {
|
||||||
appInfo.text = if (item.size == null) {
|
appInfo.text = if (item.size == null) {
|
||||||
item.time.toRelativeTime(context)
|
item.time.toRelativeTime(context)
|
||||||
|
@ -129,7 +124,17 @@ internal class AppStatusAdapter(private val toggleListener: AppStatusToggleListe
|
||||||
" (${formatShortFileSize(v.context, item.size)})"
|
" (${formatShortFileSize(v.context, item.size)})"
|
||||||
}
|
}
|
||||||
appInfo.visibility = VISIBLE
|
appInfo.visibility = VISIBLE
|
||||||
|
} else if (item.status == FAILED_WAS_STOPPED && item.time > 0) {
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
appInfo.text = if (item.size == null) {
|
||||||
|
item.time.toRelativeTime(context).toString()
|
||||||
|
} else {
|
||||||
|
item.time.toRelativeTime(context).toString() +
|
||||||
|
" (${formatShortFileSize(v.context, item.size)})"
|
||||||
|
} + "\n${item.status.getBackupText(context)}"
|
||||||
|
appInfo.visibility = VISIBLE
|
||||||
}
|
}
|
||||||
|
// setState() above sets appInfo state for other cases already
|
||||||
checkBox.visibility = INVISIBLE
|
checkBox.visibility = INVISIBLE
|
||||||
}
|
}
|
||||||
// show disabled items differently
|
// show disabled items differently
|
||||||
|
|
|
@ -5,11 +5,14 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.settings
|
package com.stevesoltys.seedvault.settings
|
||||||
|
|
||||||
|
import android.app.backup.IBackupManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
|
import androidx.preference.Preference.OnPreferenceChangeListener
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.preference.SwitchPreferenceCompat
|
import androidx.preference.TwoStatePreference
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.mms.ContentType.TEXT_PLAIN
|
import com.google.android.mms.ContentType.TEXT_PLAIN
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
|
@ -21,6 +24,9 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
private val viewModel: SettingsViewModel by sharedViewModel()
|
private val viewModel: SettingsViewModel by sharedViewModel()
|
||||||
private val packageService: PackageService by inject()
|
private val packageService: PackageService by inject()
|
||||||
|
private val backupManager: IBackupManager by inject()
|
||||||
|
|
||||||
|
private lateinit var apkBackup: TwoStatePreference
|
||||||
|
|
||||||
private val createFileLauncher =
|
private val createFileLauncher =
|
||||||
registerForActivityResult(CreateDocument(TEXT_PLAIN)) { uri ->
|
registerForActivityResult(CreateDocument(TEXT_PLAIN)) { uri ->
|
||||||
|
@ -32,6 +38,25 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
|
||||||
setPreferencesFromResource(R.xml.settings_expert, rootKey)
|
setPreferencesFromResource(R.xml.settings_expert, rootKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apkBackup = findPreference(PREF_KEY_BACKUP_APK)!!
|
||||||
|
apkBackup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
||||||
|
val enable = newValue as Boolean
|
||||||
|
if (enable) return@OnPreferenceChangeListener true
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setIcon(R.drawable.ic_warning)
|
||||||
|
.setTitle(R.string.settings_backup_apk_dialog_title)
|
||||||
|
.setMessage(R.string.settings_backup_apk_dialog_message)
|
||||||
|
.setPositiveButton(R.string.settings_backup_apk_dialog_cancel) { dialog, _ ->
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.settings_backup_apk_dialog_disable) { dialog, _ ->
|
||||||
|
apkBackup.isChecked = false
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
return@OnPreferenceChangeListener false
|
||||||
|
}
|
||||||
|
|
||||||
findPreference<Preference>("logcat")?.setOnPreferenceClickListener {
|
findPreference<Preference>("logcat")?.setOnPreferenceClickListener {
|
||||||
val versionName = packageService.getVersionName(requireContext().packageName) ?: "ver"
|
val versionName = packageService.getVersionName(requireContext().packageName) ?: "ver"
|
||||||
val timestamp = System.currentTimeMillis()
|
val timestamp = System.currentTimeMillis()
|
||||||
|
@ -39,29 +64,11 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() {
|
||||||
createFileLauncher.launch(name)
|
createFileLauncher.launch(name)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
val quotaPreference = findPreference<SwitchPreferenceCompat>(PREF_KEY_UNLIMITED_QUOTA)
|
|
||||||
|
|
||||||
quotaPreference?.setOnPreferenceChangeListener { _, newValue ->
|
|
||||||
quotaPreference.isChecked = newValue as Boolean
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
val d2dPreference = findPreference<SwitchPreferenceCompat>(PREF_KEY_D2D_BACKUPS)
|
|
||||||
|
|
||||||
d2dPreference?.setOnPreferenceChangeListener { _, newValue ->
|
|
||||||
d2dPreference.isChecked = newValue as Boolean
|
|
||||||
|
|
||||||
// automatically enable unlimited quota when enabling D2D backups
|
|
||||||
if (d2dPreference.isChecked) {
|
|
||||||
quotaPreference?.isChecked = true
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
activity?.setTitle(R.string.settings_expert_title)
|
activity?.setTitle(R.string.settings_expert_title)
|
||||||
|
apkBackup.isEnabled = backupManager.isBackupEnabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,8 @@ import androidx.preference.PreferenceManager
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
import com.stevesoltys.seedvault.settings.preference.M3ListPreference
|
import com.stevesoltys.seedvault.settings.preference.M3ListPreference
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||||
|
|
|
@ -47,7 +47,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
private lateinit var backup: TwoStatePreference
|
private lateinit var backup: TwoStatePreference
|
||||||
private lateinit var autoRestore: TwoStatePreference
|
private lateinit var autoRestore: TwoStatePreference
|
||||||
private lateinit var apkBackup: TwoStatePreference
|
|
||||||
private lateinit var backupLocation: Preference
|
private lateinit var backupLocation: Preference
|
||||||
private lateinit var backupStatus: Preference
|
private lateinit var backupStatus: Preference
|
||||||
private lateinit var backupScheduling: Preference
|
private lateinit var backupScheduling: Preference
|
||||||
|
@ -121,24 +120,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apkBackup = findPreference(PREF_KEY_BACKUP_APK)!!
|
|
||||||
apkBackup.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
|
|
||||||
val enable = newValue as Boolean
|
|
||||||
if (enable) return@OnPreferenceChangeListener true
|
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
|
||||||
.setIcon(R.drawable.ic_warning)
|
|
||||||
.setTitle(R.string.settings_backup_apk_dialog_title)
|
|
||||||
.setMessage(R.string.settings_backup_apk_dialog_message)
|
|
||||||
.setPositiveButton(R.string.settings_backup_apk_dialog_cancel) { dialog, _ ->
|
|
||||||
dialog.dismiss()
|
|
||||||
}
|
|
||||||
.setNegativeButton(R.string.settings_backup_apk_dialog_disable) { dialog, _ ->
|
|
||||||
apkBackup.isChecked = false
|
|
||||||
dialog.dismiss()
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
return@OnPreferenceChangeListener false
|
|
||||||
}
|
|
||||||
backupStatus = findPreference("backup_status")!!
|
backupStatus = findPreference("backup_status")!!
|
||||||
backupScheduling = findPreference("backup_scheduling")!!
|
backupScheduling = findPreference("backup_scheduling")!!
|
||||||
|
|
||||||
|
|
|
@ -10,10 +10,11 @@ import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||||
import android.hardware.usb.UsbDevice
|
import android.hardware.usb.UsbDevice
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.annotation.UiThread
|
import androidx.annotation.UiThread
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.stevesoltys.seedvault.backend.webdav.WebDavHandler.Companion.createWebDavProperties
|
import com.stevesoltys.seedvault.backend.webdav.WebDavHandler.Companion.createWebDavProperties
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||||
|
@ -54,25 +55,19 @@ private const val PREF_KEY_WEBDAV_PASS = "webDavPass"
|
||||||
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
|
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
|
||||||
|
|
||||||
private const val PREF_KEY_BACKUP_STORAGE = "backup_storage"
|
private const val PREF_KEY_BACKUP_STORAGE = "backup_storage"
|
||||||
internal const val PREF_KEY_UNLIMITED_QUOTA = "unlimited_quota"
|
internal const val PREF_KEY_LAST_BACKUP = "lastBackup"
|
||||||
internal const val PREF_KEY_D2D_BACKUPS = "d2d_backups"
|
|
||||||
|
|
||||||
class SettingsManager(private val context: Context) {
|
class SettingsManager(private val context: Context) {
|
||||||
|
|
||||||
private val prefs = permitDiskReads {
|
private val prefs = permitDiskReads {
|
||||||
PreferenceManager.getDefaultSharedPreferences(context)
|
PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
}
|
}
|
||||||
|
private val mLastBackupTime = MutableLiveData(prefs.getLong(PREF_KEY_LAST_BACKUP, -1))
|
||||||
|
|
||||||
@Volatile
|
/**
|
||||||
private var token: Long? = null
|
* Returns a LiveData of the last backup time in unix epoch milli seconds.
|
||||||
|
*/
|
||||||
fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
internal val lastBackupTime: LiveData<Long> = mLastBackupTime
|
||||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
|
||||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This gets accessed by non-UI threads when saving with [PreferenceManager]
|
* This gets accessed by non-UI threads when saving with [PreferenceManager]
|
||||||
|
@ -83,29 +78,26 @@ class SettingsManager(private val context: Context) {
|
||||||
ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()))
|
ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getToken(): Long? = token ?: run {
|
@Volatile
|
||||||
val value = prefs.getLong(PREF_KEY_TOKEN, 0L)
|
var token: Long? = null
|
||||||
if (value == 0L) null else value
|
private set(newToken) {
|
||||||
}
|
if (newToken == null) {
|
||||||
|
prefs.edit()
|
||||||
/**
|
.remove(PREF_KEY_TOKEN)
|
||||||
* Sets a new RestoreSet token.
|
.apply()
|
||||||
* Should only be called by the [BackupCoordinator]
|
} else {
|
||||||
* to ensure that related work is performed after moving to a new token.
|
prefs.edit()
|
||||||
*/
|
.putLong(PREF_KEY_TOKEN, newToken)
|
||||||
fun setNewToken(newToken: Long?) {
|
.apply()
|
||||||
if (newToken == null) {
|
}
|
||||||
prefs.edit()
|
field = newToken
|
||||||
.remove(PREF_KEY_TOKEN)
|
}
|
||||||
.apply()
|
// we may be able to get this from latest snapshot,
|
||||||
} else {
|
// but that is not always readily available
|
||||||
prefs.edit()
|
get() = field ?: run {
|
||||||
.putLong(PREF_KEY_TOKEN, newToken)
|
val value = prefs.getLong(PREF_KEY_TOKEN, 0L)
|
||||||
.apply()
|
if (value == 0L) null else value
|
||||||
}
|
}
|
||||||
|
|
||||||
token = newToken
|
|
||||||
}
|
|
||||||
|
|
||||||
internal val storagePluginType: StoragePluginType?
|
internal val storagePluginType: StoragePluginType?
|
||||||
get() {
|
get() {
|
||||||
|
@ -128,6 +120,21 @@ class SettingsManager(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
||||||
|
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) {
|
||||||
|
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSuccessfulBackupCompleted(token: Long) {
|
||||||
|
this.token = token
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
prefs.edit().putLong(PREF_KEY_LAST_BACKUP, now).apply()
|
||||||
|
mLastBackupTime.postValue(now)
|
||||||
|
}
|
||||||
|
|
||||||
fun setStorageBackend(plugin: Backend) {
|
fun setStorageBackend(plugin: Backend) {
|
||||||
val value = when (plugin) {
|
val value = when (plugin) {
|
||||||
is SafBackend -> StoragePluginType.SAF
|
is SafBackend -> StoragePluginType.SAF
|
||||||
|
@ -238,15 +245,7 @@ class SettingsManager(private val context: Context) {
|
||||||
prefs.edit().putStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, blacklistedApps).apply()
|
prefs.edit().putStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, blacklistedApps).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isQuotaUnlimited() = prefs.getBoolean(PREF_KEY_UNLIMITED_QUOTA, false)
|
val quota: Long = 1024 * 1024 * 1024 // 1 GiB for now
|
||||||
|
|
||||||
fun d2dBackupsEnabled() = prefs.getBoolean(PREF_KEY_D2D_BACKUPS, false)
|
|
||||||
|
|
||||||
fun setD2dBackupsEnabled(enabled: Boolean) {
|
|
||||||
prefs.edit()
|
|
||||||
.putBoolean(PREF_KEY_D2D_BACKUPS, enabled)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This assumes that if there's no storage plugin set, it is the first start.
|
* This assumes that if there's no storage plugin set, it is the first start.
|
||||||
|
|
|
@ -35,12 +35,10 @@ import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.stevesoltys.seedvault.BackupStateManager
|
import com.stevesoltys.seedvault.BackupStateManager
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
|
||||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
|
||||||
|
@ -68,11 +66,9 @@ internal class SettingsViewModel(
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
keyManager: KeyManager,
|
keyManager: KeyManager,
|
||||||
backendManager: BackendManager,
|
backendManager: BackendManager,
|
||||||
private val metadataManager: MetadataManager,
|
|
||||||
private val appListRetriever: AppListRetriever,
|
private val appListRetriever: AppListRetriever,
|
||||||
private val storageBackup: StorageBackup,
|
private val storageBackup: StorageBackup,
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
private val backupInitializer: BackupInitializer,
|
|
||||||
backupStateManager: BackupStateManager,
|
backupStateManager: BackupStateManager,
|
||||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager, backendManager) {
|
) : RequireProvisioningViewModel(app, settingsManager, keyManager, backendManager) {
|
||||||
|
|
||||||
|
@ -88,7 +84,7 @@ internal class SettingsViewModel(
|
||||||
private val mBackupPossible = MutableLiveData(false)
|
private val mBackupPossible = MutableLiveData(false)
|
||||||
val backupPossible: LiveData<Boolean> = mBackupPossible
|
val backupPossible: LiveData<Boolean> = mBackupPossible
|
||||||
|
|
||||||
internal val lastBackupTime = metadataManager.lastBackupTime
|
internal val lastBackupTime = settingsManager.lastBackupTime
|
||||||
internal val appBackupWorkInfo =
|
internal val appBackupWorkInfo =
|
||||||
workManager.getWorkInfosForUniqueWorkLiveData(UNIQUE_WORK_NAME).map {
|
workManager.getWorkInfosForUniqueWorkLiveData(UNIQUE_WORK_NAME).map {
|
||||||
it.getOrNull(0)
|
it.getOrNull(0)
|
||||||
|
@ -143,8 +139,6 @@ internal class SettingsViewModel(
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// ensures the lastBackupTime LiveData gets set
|
|
||||||
metadataManager.getLastBackupTime()
|
|
||||||
// update running state
|
// update running state
|
||||||
isBackupRunning.collect {
|
isBackupRunning.collect {
|
||||||
onBackupRunningStateChanged()
|
onBackupRunningStateChanged()
|
||||||
|
@ -258,21 +252,6 @@ internal class SettingsViewModel(
|
||||||
|
|
||||||
fun onBackupEnabled(enabled: Boolean) {
|
fun onBackupEnabled(enabled: Boolean) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
if (metadataManager.requiresInit) {
|
|
||||||
val onError: () -> Unit = {
|
|
||||||
viewModelScope.launch(Dispatchers.Main) {
|
|
||||||
val res = R.string.storage_check_fragment_backup_error
|
|
||||||
Toast.makeText(app, res, LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
backupInitializer.initialize(onError) {
|
|
||||||
mInitEvent.postEvent(false)
|
|
||||||
scheduleAppBackup(CANCEL_AND_REENQUEUE)
|
|
||||||
}
|
|
||||||
mInitEvent.postEvent(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// enable call log backups for existing installs (added end of 2020)
|
// enable call log backups for existing installs (added end of 2020)
|
||||||
enableCallLogBackup()
|
enableCallLogBackup()
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -5,8 +5,8 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.storage
|
package com.stevesoltys.seedvault.storage
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
import org.calyxos.backup.storage.api.StorageBackup
|
import org.calyxos.backup.storage.api.StorageBackup
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,6 @@ import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.settings.SettingsActivity
|
import com.stevesoltys.seedvault.settings.SettingsActivity
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||||
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
@ -43,7 +42,6 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
||||||
|
|
||||||
private val backupCoordinator by inject<BackupCoordinator>()
|
private val backupCoordinator by inject<BackupCoordinator>()
|
||||||
private val restoreCoordinator by inject<RestoreCoordinator>()
|
private val restoreCoordinator by inject<RestoreCoordinator>()
|
||||||
private val settingsManager by inject<SettingsManager>()
|
|
||||||
|
|
||||||
override fun transportDirName(): String {
|
override fun transportDirName(): String {
|
||||||
return TRANSPORT_DIRECTORY_NAME
|
return TRANSPORT_DIRECTORY_NAME
|
||||||
|
@ -62,13 +60,7 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
||||||
* which is accessible to the BackupAgent.
|
* which is accessible to the BackupAgent.
|
||||||
* This allows the agent to decide what to do based on properties of the transport.
|
* This allows the agent to decide what to do based on properties of the transport.
|
||||||
*/
|
*/
|
||||||
override fun getTransportFlags(): Int {
|
override fun getTransportFlags(): Int = D2D_TRANSPORT_FLAGS
|
||||||
return if (settingsManager.d2dBackupsEnabled()) {
|
|
||||||
D2D_TRANSPORT_FLAGS
|
|
||||||
} else {
|
|
||||||
DEFAULT_TRANSPORT_FLAGS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the transport for an [Intent] that can be used to launch
|
* Ask the transport for an [Intent] that can be used to launch
|
||||||
|
@ -135,8 +127,8 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
|
||||||
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
|
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking {
|
||||||
return backupCoordinator.getBackupQuota(packageName, isFullBackup)
|
backupCoordinator.getBackupQuota(packageName, isFullBackup)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking {
|
override fun clearBackupData(packageInfo: PackageInfo): Int = runBlocking {
|
||||||
|
|
|
@ -11,9 +11,12 @@ import android.content.Intent
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
|
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
@ -34,6 +37,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
||||||
|
|
||||||
private val keyManager: KeyManager by inject()
|
private val keyManager: KeyManager by inject()
|
||||||
private val backupManager: IBackupManager by inject()
|
private val backupManager: IBackupManager by inject()
|
||||||
|
private val appBackupManager: AppBackupManager by inject()
|
||||||
private val notificationManager: BackupNotificationManager by inject()
|
private val notificationManager: BackupNotificationManager by inject()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
@ -45,7 +49,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
// refuse to work until we have the main key
|
// refuse to work until we have the main key
|
||||||
val noMainKey = keyManager.hasBackupKey() && !keyManager.hasMainKey()
|
val noMainKey = permitDiskReads { keyManager.hasBackupKey() && !keyManager.hasMainKey() }
|
||||||
if (noMainKey && backupManager.currentTransport == TRANSPORT_ID) {
|
if (noMainKey && backupManager.currentTransport == TRANSPORT_ID) {
|
||||||
notificationManager.onNoMainKeyError()
|
notificationManager.onNoMainKeyError()
|
||||||
backupManager.isBackupEnabled = false
|
backupManager.isBackupEnabled = false
|
||||||
|
@ -61,6 +65,11 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
||||||
notificationManager.onServiceDestroyed()
|
notificationManager.onServiceDestroyed()
|
||||||
transport = null
|
transport = null
|
||||||
mIsRunning.value = false
|
mIsRunning.value = false
|
||||||
|
runBlocking {
|
||||||
|
// This is a hack for `adb shell bmgr backupnow`. Better would be a foreground service,
|
||||||
|
// but since this isn't a typical use-case we don't bother for now.
|
||||||
|
appBackupManager.finalizeBackupIfNeeded()
|
||||||
|
}
|
||||||
Log.d(TAG, "Service destroyed.")
|
Log.d(TAG, "Service destroyed.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,25 +15,23 @@ import android.app.backup.BackupTransport.TRANSPORT_NOT_INITIALIZED
|
||||||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||||
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||||
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
||||||
import android.app.backup.RestoreSet
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.seedvault.Clock
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
|
||||||
import com.stevesoltys.seedvault.metadata.BackupType
|
import com.stevesoltys.seedvault.metadata.BackupType
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState
|
import com.stevesoltys.seedvault.metadata.PackageState
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||||
import com.stevesoltys.seedvault.backend.getMetadataOutputStream
|
|
||||||
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
|
import com.stevesoltys.seedvault.ui.systemData
|
||||||
|
import org.calyxos.seedvault.core.backends.isOutOfSpace
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit.DAYS
|
import java.util.concurrent.TimeUnit.DAYS
|
||||||
import java.util.concurrent.TimeUnit.HOURS
|
import java.util.concurrent.TimeUnit.HOURS
|
||||||
|
@ -63,42 +61,30 @@ private class CoordinatorState(
|
||||||
internal class BackupCoordinator(
|
internal class BackupCoordinator(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val backendManager: BackendManager,
|
private val backendManager: BackendManager,
|
||||||
|
private val appBackupManager: AppBackupManager,
|
||||||
private val kv: KVBackup,
|
private val kv: KVBackup,
|
||||||
private val full: FullBackup,
|
private val full: FullBackup,
|
||||||
private val clock: Clock,
|
|
||||||
private val packageService: PackageService,
|
private val packageService: PackageService,
|
||||||
private val metadataManager: MetadataManager,
|
private val metadataManager: MetadataManager,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val nm: BackupNotificationManager,
|
private val nm: BackupNotificationManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val backend get() = backendManager.backend
|
private val snapshotCreator
|
||||||
|
get() = appBackupManager.snapshotCreator ?: error("No SnapshotCreator")
|
||||||
private val state = CoordinatorState(
|
private val state = CoordinatorState(
|
||||||
calledInitialize = false,
|
calledInitialize = false,
|
||||||
calledClearBackupData = false,
|
calledClearBackupData = false,
|
||||||
cancelReason = UNKNOWN_ERROR
|
cancelReason = UNKNOWN_ERROR
|
||||||
)
|
)
|
||||||
|
private val launchableSystemApps by lazy {
|
||||||
|
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
// Transport initialization and quota
|
// Transport initialization and quota
|
||||||
//
|
//
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts a new [RestoreSet] with a new token (the current unix epoch in milliseconds).
|
|
||||||
* Call this at least once before calling [initializeDevice]
|
|
||||||
* which must be called after this method to properly initialize the backup transport.
|
|
||||||
*
|
|
||||||
* @return the token of the new [RestoreSet].
|
|
||||||
*/
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private suspend fun startNewRestoreSet() {
|
|
||||||
val token = clock.time()
|
|
||||||
Log.i(TAG, "Starting new RestoreSet with token $token...")
|
|
||||||
settingsManager.setNewToken(token)
|
|
||||||
Log.d(TAG, "Resetting backup metadata...")
|
|
||||||
metadataManager.onDeviceInitialization(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the storage for this device, erasing all stored data.
|
* Initialize the storage for this device, erasing all stored data.
|
||||||
* The transport may send the request immediately, or may buffer it.
|
* The transport may send the request immediately, or may buffer it.
|
||||||
|
@ -107,6 +93,8 @@ internal class BackupCoordinator(
|
||||||
*
|
*
|
||||||
* If the transport returns anything other than [TRANSPORT_OK] from this method,
|
* If the transport returns anything other than [TRANSPORT_OK] from this method,
|
||||||
* the OS will halt the current initialize operation and schedule a retry in the near future.
|
* the OS will halt the current initialize operation and schedule a retry in the near future.
|
||||||
|
* Attention: [finishBackup] will not be called in this case.
|
||||||
|
*
|
||||||
* Even if the transport is in a state
|
* Even if the transport is in a state
|
||||||
* such that attempting to "initialize" the backend storage is meaningless -
|
* such that attempting to "initialize" the backend storage is meaningless -
|
||||||
* for example, if there is no current live data-set at all,
|
* for example, if there is no current live data-set at all,
|
||||||
|
@ -117,20 +105,11 @@ internal class BackupCoordinator(
|
||||||
* @return One of [TRANSPORT_OK] (OK so far) or
|
* @return One of [TRANSPORT_OK] (OK so far) or
|
||||||
* [TRANSPORT_ERROR] (to retry following network error or other failure).
|
* [TRANSPORT_ERROR] (to retry following network error or other failure).
|
||||||
*/
|
*/
|
||||||
suspend fun initializeDevice(): Int = try {
|
fun initializeDevice(): Int {
|
||||||
// we don't respect the intended system behavior here by always starting a new [RestoreSet]
|
|
||||||
// instead of simply deleting the current one
|
|
||||||
startNewRestoreSet()
|
|
||||||
Log.i(TAG, "Initialize Device!")
|
Log.i(TAG, "Initialize Device!")
|
||||||
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
|
// we don't respect the intended system behavior of erasing all stored data
|
||||||
// so we remember that we initialized successfully
|
|
||||||
state.calledInitialize = true
|
state.calledInitialize = true
|
||||||
TRANSPORT_OK
|
return TRANSPORT_OK
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Error initializing device", e)
|
|
||||||
// Show error notification if we needed init or were ready for backups
|
|
||||||
if (metadataManager.requiresInit || backendManager.canDoBackupNow()) nm.onBackupError()
|
|
||||||
TRANSPORT_ERROR
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isAppEligibleForBackup(
|
fun isAppEligibleForBackup(
|
||||||
|
@ -151,10 +130,15 @@ internal class BackupCoordinator(
|
||||||
* otherwise for key-value backup.
|
* otherwise for key-value backup.
|
||||||
* @return Current limit on backup size in bytes.
|
* @return Current limit on backup size in bytes.
|
||||||
*/
|
*/
|
||||||
fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
suspend fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
|
||||||
// report back quota
|
|
||||||
Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.")
|
Log.i(TAG, "Get backup quota for $packageName. Is full backup: $isFullBackup.")
|
||||||
val quota = if (isFullBackup) full.getQuota() else kv.getQuota()
|
|
||||||
|
if (!isFullBackup) {
|
||||||
|
// hack for `adb shell bmgr backupnow`
|
||||||
|
// which starts with a K/V backup calling this method, so we hook in here
|
||||||
|
appBackupManager.ensureBackupPrepared()
|
||||||
|
}
|
||||||
|
val quota = settingsManager.quota
|
||||||
Log.i(TAG, "Reported quota of $quota bytes.")
|
Log.i(TAG, "Reported quota of $quota bytes.")
|
||||||
return quota
|
return quota
|
||||||
}
|
}
|
||||||
|
@ -214,24 +198,13 @@ internal class BackupCoordinator(
|
||||||
* [TRANSPORT_NOT_INITIALIZED] (if the backend dataset has become lost due to
|
* [TRANSPORT_NOT_INITIALIZED] (if the backend dataset has become lost due to
|
||||||
* inactivity purge or some other reason and needs re-initializing)
|
* inactivity purge or some other reason and needs re-initializing)
|
||||||
*/
|
*/
|
||||||
suspend fun performIncrementalBackup(
|
fun performIncrementalBackup(
|
||||||
packageInfo: PackageInfo,
|
packageInfo: PackageInfo,
|
||||||
data: ParcelFileDescriptor,
|
data: ParcelFileDescriptor,
|
||||||
flags: Int,
|
flags: Int,
|
||||||
): Int {
|
): Int {
|
||||||
state.cancelReason = UNKNOWN_ERROR
|
state.cancelReason = UNKNOWN_ERROR
|
||||||
if (metadataManager.requiresInit) {
|
return kv.performBackup(packageInfo, data, flags)
|
||||||
Log.w(TAG, "Metadata requires re-init!")
|
|
||||||
// Tell the system that we are not initialized, it will initialize us afterwards.
|
|
||||||
// This will start a new restore set to upgrade from legacy format
|
|
||||||
// by starting a clean backup with all files using the new version.
|
|
||||||
//
|
|
||||||
// This causes a backup error, but things should go back to normal afterwards.
|
|
||||||
return TRANSPORT_NOT_INITIALIZED
|
|
||||||
}
|
|
||||||
val token = settingsManager.getToken() ?: error("no token in performFullBackup")
|
|
||||||
val salt = metadataManager.salt
|
|
||||||
return kv.performBackup(packageInfo, data, flags, token, salt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
|
@ -262,15 +235,13 @@ internal class BackupCoordinator(
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun performFullBackup(
|
fun performFullBackup(
|
||||||
targetPackage: PackageInfo,
|
targetPackage: PackageInfo,
|
||||||
fileDescriptor: ParcelFileDescriptor,
|
fileDescriptor: ParcelFileDescriptor,
|
||||||
flags: Int,
|
flags: Int,
|
||||||
): Int {
|
): Int {
|
||||||
state.cancelReason = UNKNOWN_ERROR
|
state.cancelReason = UNKNOWN_ERROR
|
||||||
val token = settingsManager.getToken() ?: error("no token in performFullBackup")
|
return full.performFullBackup(targetPackage, fileDescriptor, flags)
|
||||||
val salt = metadataManager.salt
|
|
||||||
return full.performFullBackup(targetPackage, fileDescriptor, flags, token, salt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -299,18 +270,17 @@ internal class BackupCoordinator(
|
||||||
* It needs to tear down any ongoing backup state here.
|
* It needs to tear down any ongoing backup state here.
|
||||||
*/
|
*/
|
||||||
suspend fun cancelFullBackup() {
|
suspend fun cancelFullBackup() {
|
||||||
val packageInfo = full.getCurrentPackage()
|
val packageInfo = full.currentPackageInfo
|
||||||
?: throw AssertionError("Cancelling full backup, but no current package")
|
?: error("Cancelling full backup, but no current package")
|
||||||
Log.i(
|
val packageName = packageInfo.packageName
|
||||||
TAG, "Cancel full backup of ${packageInfo.packageName}" +
|
Log.i(TAG, "Cancel full backup of $packageName because of ${state.cancelReason}")
|
||||||
" because of ${state.cancelReason}"
|
// don't bother with remembering state for boring system apps that have no data
|
||||||
)
|
val ignoreApp = state.cancelReason == NO_DATA &&
|
||||||
// don't bother with system apps that have no data
|
packageInfo.isSystemApp() &&
|
||||||
val ignoreApp = state.cancelReason == NO_DATA && packageInfo.isSystemApp()
|
packageName !in systemData.keys && // don't ignore our special system apps
|
||||||
|
packageName !in launchableSystemApps // don't ignore launchable system apps
|
||||||
if (!ignoreApp) onPackageBackupError(packageInfo, BackupType.FULL)
|
if (!ignoreApp) onPackageBackupError(packageInfo, BackupType.FULL)
|
||||||
val token = settingsManager.getToken() ?: error("no token in cancelFullBackup")
|
full.cancelFullBackup()
|
||||||
val salt = metadataManager.salt
|
|
||||||
full.cancelFullBackup(token, salt, ignoreApp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear and Finish
|
// Clear and Finish
|
||||||
|
@ -324,23 +294,9 @@ internal class BackupCoordinator(
|
||||||
*
|
*
|
||||||
* @return the same error codes as [performFullBackup].
|
* @return the same error codes as [performFullBackup].
|
||||||
*/
|
*/
|
||||||
suspend fun clearBackupData(packageInfo: PackageInfo): Int {
|
fun clearBackupData(packageInfo: PackageInfo): Int {
|
||||||
val packageName = packageInfo.packageName
|
Log.i(TAG, "Ignoring clear backup data of ${packageInfo.packageName}.")
|
||||||
Log.i(TAG, "Clear Backup Data of $packageName.")
|
// we don't clear backup data anymore, we have snapshots and those old ones stay valid
|
||||||
val token = settingsManager.getToken() ?: error("no token in clearBackupData")
|
|
||||||
val salt = metadataManager.salt
|
|
||||||
try {
|
|
||||||
kv.clearBackupData(packageInfo, token, salt)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.w(TAG, "Error clearing K/V backup data for $packageName", e)
|
|
||||||
return TRANSPORT_ERROR
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
full.clearBackupData(packageInfo, token, salt)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.w(TAG, "Error clearing full backup data for $packageName", e)
|
|
||||||
return TRANSPORT_ERROR
|
|
||||||
}
|
|
||||||
state.calledClearBackupData = true
|
state.calledClearBackupData = true
|
||||||
return TRANSPORT_OK
|
return TRANSPORT_OK
|
||||||
}
|
}
|
||||||
|
@ -354,49 +310,43 @@ internal class BackupCoordinator(
|
||||||
* @return the same error codes as [performIncrementalBackup] or [performFullBackup].
|
* @return the same error codes as [performIncrementalBackup] or [performFullBackup].
|
||||||
*/
|
*/
|
||||||
suspend fun finishBackup(): Int = when {
|
suspend fun finishBackup(): Int = when {
|
||||||
kv.hasState() -> {
|
kv.hasState -> {
|
||||||
check(!full.hasState()) {
|
check(!full.hasState) {
|
||||||
"K/V backup has state, but full backup has dangling state as well"
|
"K/V backup has state, but full backup has dangling state as well"
|
||||||
}
|
}
|
||||||
// getCurrentPackage() not-null because we have state, call before finishing
|
// getCurrentPackage() not-null because we have state, call before finishing
|
||||||
val packageInfo = kv.getCurrentPackage()!!
|
val packageInfo = kv.currentPackageInfo!!
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
val size = kv.getCurrentSize()
|
try {
|
||||||
// tell K/V backup to finish
|
// tell K/V backup to finish
|
||||||
var result = kv.finishBackup()
|
val backupData = kv.finishBackup()
|
||||||
if (result == TRANSPORT_OK) {
|
snapshotCreator.onPackageBackedUp(packageInfo, BackupType.KV, backupData)
|
||||||
val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER
|
TRANSPORT_OK
|
||||||
// call onPackageBackedUp for @pm@ only if we can do backups right now
|
} catch (e: Exception) {
|
||||||
if (isNormalBackup || backendManager.canDoBackupNow()) {
|
Log.e(TAG, "Error finishing K/V backup for $packageName", e)
|
||||||
try {
|
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
||||||
onPackageBackedUp(packageInfo, BackupType.KV, size)
|
onPackageBackupError(packageInfo, BackupType.KV)
|
||||||
} catch (e: Exception) {
|
TRANSPORT_PACKAGE_REJECTED
|
||||||
Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
|
|
||||||
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
|
||||||
result = TRANSPORT_PACKAGE_REJECTED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
result
|
|
||||||
}
|
}
|
||||||
full.hasState() -> {
|
full.hasState -> {
|
||||||
check(!kv.hasState()) {
|
check(!kv.hasState) {
|
||||||
"Full backup has state, but K/V backup has dangling state as well"
|
"Full backup has state, but K/V backup has dangling state as well"
|
||||||
}
|
}
|
||||||
// getCurrentPackage() not-null because we have state
|
// getCurrentPackage() not-null because we have state
|
||||||
val packageInfo = full.getCurrentPackage()!!
|
val packageInfo = full.currentPackageInfo!!
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
val size = full.getCurrentSize()
|
|
||||||
// tell full backup to finish
|
// tell full backup to finish
|
||||||
var result = full.finishBackup()
|
|
||||||
try {
|
try {
|
||||||
onPackageBackedUp(packageInfo, BackupType.FULL, size)
|
val backupData = full.finishBackup()
|
||||||
|
snapshotCreator.onPackageBackedUp(packageInfo, BackupType.FULL, backupData)
|
||||||
|
TRANSPORT_OK
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
|
Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
|
||||||
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
||||||
result = TRANSPORT_PACKAGE_REJECTED
|
onPackageBackupError(packageInfo, BackupType.FULL)
|
||||||
|
TRANSPORT_PACKAGE_REJECTED
|
||||||
}
|
}
|
||||||
result
|
|
||||||
}
|
}
|
||||||
state.expectFinish -> {
|
state.expectFinish -> {
|
||||||
state.onFinish()
|
state.onFinish()
|
||||||
|
@ -405,20 +355,10 @@ internal class BackupCoordinator(
|
||||||
else -> throw IllegalStateException("Unexpected state in finishBackup()")
|
else -> throw IllegalStateException("Unexpected state in finishBackup()")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onPackageBackedUp(packageInfo: PackageInfo, type: BackupType, size: Long?) {
|
private fun onPackageBackupError(packageInfo: PackageInfo, type: BackupType) {
|
||||||
val token = settingsManager.getToken() ?: error("no token")
|
|
||||||
backend.getMetadataOutputStream(token).use {
|
|
||||||
metadataManager.onPackageBackedUp(packageInfo, type, size, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun onPackageBackupError(packageInfo: PackageInfo, type: BackupType) {
|
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
try {
|
try {
|
||||||
val token = settingsManager.getToken() ?: error("no token")
|
metadataManager.onPackageBackupError(packageInfo, state.cancelReason, type)
|
||||||
backend.getMetadataOutputStream(token).use {
|
|
||||||
metadataManager.onPackageBackupError(packageInfo, state.cancelReason, it, type)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error while writing metadata for $packageName", e)
|
Log.e(TAG, "Error while writing metadata for $packageName", e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,8 +11,6 @@ import android.app.backup.IBackupObserver
|
||||||
import android.os.UserHandle
|
import android.os.UserHandle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.seedvault.BackupMonitor
|
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
|
||||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||||
|
|
||||||
class BackupInitializer(
|
class BackupInitializer(
|
||||||
|
@ -25,17 +23,7 @@ class BackupInitializer(
|
||||||
|
|
||||||
fun initialize(onError: () -> Unit, onSuccess: () -> Unit) {
|
fun initialize(onError: () -> Unit, onSuccess: () -> Unit) {
|
||||||
val observer = BackupObserver("Initialization", onError) {
|
val observer = BackupObserver("Initialization", onError) {
|
||||||
// After successful initialization, we request a @pm@ backup right away,
|
onSuccess()
|
||||||
// because if this finds empty state, it asks us to do another initialization.
|
|
||||||
// And then we end up with yet another restore set token.
|
|
||||||
// Since we want the final token as soon as possible, we need to get it here.
|
|
||||||
Log.d(TAG, "Requesting initial $MAGIC_PACKAGE_MANAGER backup...")
|
|
||||||
backupManager.requestBackup(
|
|
||||||
arrayOf(MAGIC_PACKAGE_MANAGER),
|
|
||||||
BackupObserver("Initial backup of @pm@", onError, onSuccess),
|
|
||||||
BackupMonitor(),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
backupManager.initializeTransportsForUser(
|
backupManager.initializeTransportsForUser(
|
||||||
UserHandle.myUserId(),
|
UserHandle.myUserId(),
|
||||||
|
|
|
@ -9,12 +9,12 @@ import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val backupModule = module {
|
val backupModule = module {
|
||||||
|
factory { BackupTransportMonitor(get(), get()) }
|
||||||
single { BackupInitializer(get()) }
|
single { BackupInitializer(get()) }
|
||||||
single { InputFactory() }
|
single { InputFactory() }
|
||||||
single {
|
single {
|
||||||
PackageService(
|
PackageService(
|
||||||
context = androidContext(),
|
context = androidContext(),
|
||||||
backupManager = get(),
|
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
backendManager = get(),
|
backendManager = get(),
|
||||||
)
|
)
|
||||||
|
@ -22,30 +22,26 @@ val backupModule = module {
|
||||||
single<KvDbManager> { KvDbManagerImpl(androidContext()) }
|
single<KvDbManager> { KvDbManagerImpl(androidContext()) }
|
||||||
single {
|
single {
|
||||||
KVBackup(
|
KVBackup(
|
||||||
backendManager = get(),
|
backupReceiver = get(),
|
||||||
settingsManager = get(),
|
|
||||||
nm = get(),
|
|
||||||
inputFactory = get(),
|
inputFactory = get(),
|
||||||
crypto = get(),
|
|
||||||
dbManager = get(),
|
dbManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single {
|
single {
|
||||||
FullBackup(
|
FullBackup(
|
||||||
backendManager = get(),
|
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
nm = get(),
|
nm = get(),
|
||||||
|
backupReceiver = get(),
|
||||||
inputFactory = get(),
|
inputFactory = get(),
|
||||||
crypto = get(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single {
|
single {
|
||||||
BackupCoordinator(
|
BackupCoordinator(
|
||||||
context = androidContext(),
|
context = androidContext(),
|
||||||
backendManager = get(),
|
backendManager = get(),
|
||||||
|
appBackupManager = get(),
|
||||||
kv = get(),
|
kv = get(),
|
||||||
full = get(),
|
full = get(),
|
||||||
clock = get(),
|
|
||||||
packageService = get(),
|
packageService = get(),
|
||||||
metadataManager = get(),
|
metadataManager = get(),
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.transport.backup
|
||||||
|
|
||||||
|
import android.app.backup.BackupManagerMonitor.LOG_EVENT_CATEGORY_BACKUP_MANAGER_POLICY
|
||||||
|
import android.app.backup.BackupManagerMonitor.LOG_EVENT_ID_NO_DATA_TO_SEND
|
||||||
|
import android.os.Bundle
|
||||||
|
import com.stevesoltys.seedvault.BackupMonitor
|
||||||
|
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||||
|
import com.stevesoltys.seedvault.repo.SnapshotManager
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
|
||||||
|
internal class BackupTransportMonitor(
|
||||||
|
private val appBackupManager: AppBackupManager,
|
||||||
|
private val snapshotManager: SnapshotManager,
|
||||||
|
) : BackupMonitor() {
|
||||||
|
|
||||||
|
private val log = KotlinLogging.logger { }
|
||||||
|
|
||||||
|
override fun onEvent(id: Int, category: Int, packageName: String?, bundle: Bundle) {
|
||||||
|
super.onEvent(id, category, packageName, bundle)
|
||||||
|
if (packageName != null && id == LOG_EVENT_ID_NO_DATA_TO_SEND &&
|
||||||
|
category == LOG_EVENT_CATEGORY_BACKUP_MANAGER_POLICY
|
||||||
|
) {
|
||||||
|
sendNoDataChanged(packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendNoDataChanged(packageName: String) {
|
||||||
|
log.info { "sendNoDataChanged($packageName)" }
|
||||||
|
|
||||||
|
val snapshot = snapshotManager.latestSnapshot
|
||||||
|
if (snapshot == null) {
|
||||||
|
log.error { "No latest snapshot!" }
|
||||||
|
} else {
|
||||||
|
val snapshotCreator = appBackupManager.snapshotCreator ?: error("No SnapshotCreator")
|
||||||
|
snapshotCreator.onNoDataInCurrentRun(snapshot, packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,65 +13,46 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.repo.BackupData
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.repo.BackupReceiver
|
||||||
import com.stevesoltys.seedvault.header.getADForFull
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
import org.calyxos.seedvault.core.backends.isOutOfSpace
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
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
|
|
||||||
|
|
||||||
private class FullBackupState(
|
private class FullBackupState(
|
||||||
val packageInfo: PackageInfo,
|
val packageInfo: PackageInfo,
|
||||||
val inputFileDescriptor: ParcelFileDescriptor,
|
val inputFileDescriptor: ParcelFileDescriptor,
|
||||||
val inputStream: InputStream,
|
val inputStream: InputStream,
|
||||||
var outputStreamInit: (suspend () -> OutputStream)?,
|
|
||||||
) {
|
) {
|
||||||
/**
|
|
||||||
* This is an encrypted stream that can be written to directly.
|
|
||||||
*/
|
|
||||||
var outputStream: OutputStream? = null
|
|
||||||
val packageName: String = packageInfo.packageName
|
val packageName: String = packageInfo.packageName
|
||||||
var size: Long = 0
|
var size: Long = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const val DEFAULT_QUOTA_FULL_BACKUP = (2 * (25 * 1024 * 1024)).toLong()
|
|
||||||
|
|
||||||
private val TAG = FullBackup::class.java.simpleName
|
private val TAG = FullBackup::class.java.simpleName
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class FullBackup(
|
internal class FullBackup(
|
||||||
private val backendManager: BackendManager,
|
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val nm: BackupNotificationManager,
|
private val nm: BackupNotificationManager,
|
||||||
|
private val backupReceiver: BackupReceiver,
|
||||||
private val inputFactory: InputFactory,
|
private val inputFactory: InputFactory,
|
||||||
private val crypto: Crypto,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val backend get() = backendManager.backend
|
|
||||||
private var state: FullBackupState? = null
|
private var state: FullBackupState? = null
|
||||||
|
|
||||||
fun hasState() = state != null
|
val hasState: Boolean get() = state != null
|
||||||
|
val currentPackageInfo get() = state?.packageInfo
|
||||||
fun getCurrentPackage() = state?.packageInfo
|
val quota get() = settingsManager.quota
|
||||||
|
|
||||||
fun getCurrentSize() = state?.size
|
|
||||||
|
|
||||||
fun getQuota(): Long {
|
|
||||||
return if (settingsManager.isQuotaUnlimited()) Long.MAX_VALUE else DEFAULT_QUOTA_FULL_BACKUP
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkFullBackupSize(size: Long): Int {
|
fun checkFullBackupSize(size: Long): Int {
|
||||||
Log.i(TAG, "Check full backup size of $size bytes.")
|
Log.i(TAG, "Check full backup size of $size bytes.")
|
||||||
return when {
|
return when {
|
||||||
size <= 0 -> TRANSPORT_PACKAGE_REJECTED
|
size <= 0 -> TRANSPORT_PACKAGE_REJECTED
|
||||||
size > getQuota() -> TRANSPORT_QUOTA_EXCEEDED
|
size > quota -> TRANSPORT_QUOTA_EXCEEDED
|
||||||
else -> TRANSPORT_OK
|
else -> TRANSPORT_OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -111,71 +92,41 @@ internal class FullBackup(
|
||||||
* [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data;
|
* [TRANSPORT_OK] to indicate that the OS may proceed with delivering backup data;
|
||||||
* [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time.
|
* [TRANSPORT_ERROR] to indicate an error that precludes performing a backup at this time.
|
||||||
*/
|
*/
|
||||||
suspend fun performFullBackup(
|
fun performFullBackup(
|
||||||
targetPackage: PackageInfo,
|
targetPackage: PackageInfo,
|
||||||
socket: ParcelFileDescriptor,
|
socket: ParcelFileDescriptor,
|
||||||
@Suppress("UNUSED_PARAMETER") flags: Int = 0,
|
@Suppress("UNUSED_PARAMETER") flags: Int = 0,
|
||||||
token: Long,
|
|
||||||
salt: String,
|
|
||||||
): Int {
|
): Int {
|
||||||
if (state != null) throw AssertionError()
|
if (state != null) error("state wasn't initialized for $targetPackage")
|
||||||
val packageName = targetPackage.packageName
|
val packageName = targetPackage.packageName
|
||||||
Log.i(TAG, "Perform full backup for $packageName.")
|
Log.i(TAG, "Perform full backup for $packageName.")
|
||||||
|
|
||||||
// create new state
|
// create new state
|
||||||
val inputStream = inputFactory.getInputStream(socket)
|
val inputStream = inputFactory.getInputStream(socket)
|
||||||
state = FullBackupState(targetPackage, socket, inputStream) {
|
state = FullBackupState(targetPackage, socket, inputStream)
|
||||||
Log.d(TAG, "Initializing OutputStream for $packageName.")
|
|
||||||
val name = crypto.getNameForPackage(salt, packageName)
|
|
||||||
// get OutputStream to write backup data into
|
|
||||||
val outputStream = try {
|
|
||||||
backend.save(LegacyAppBackupFile.Blob(token, name))
|
|
||||||
} catch (e: IOException) {
|
|
||||||
"Error getting OutputStream for full backup of $packageName".let {
|
|
||||||
Log.e(TAG, it, e)
|
|
||||||
}
|
|
||||||
throw(e)
|
|
||||||
}
|
|
||||||
// store version header
|
|
||||||
val state = this.state ?: throw AssertionError()
|
|
||||||
outputStream.write(ByteArray(1) { VERSION })
|
|
||||||
crypto.newEncryptingStream(outputStream, getADForFull(VERSION, state.packageName))
|
|
||||||
} // this lambda is only called before we actually write backup data the first time
|
|
||||||
return TRANSPORT_OK
|
return TRANSPORT_OK
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sendBackupData(numBytes: Int): Int {
|
suspend fun sendBackupData(numBytes: Int): Int {
|
||||||
val state = this.state
|
val state = this.state ?: error("Attempted sendBackupData before performFullBackup")
|
||||||
?: throw AssertionError("Attempted sendBackupData before performFullBackup")
|
|
||||||
|
|
||||||
// check if size fits quota
|
// check if size fits quota
|
||||||
state.size += numBytes
|
val newSize = state.size + numBytes
|
||||||
val quota = getQuota()
|
if (newSize > quota) {
|
||||||
if (state.size > quota) {
|
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"Full backup of additional $numBytes exceeds quota of $quota with ${state.size}."
|
"Full backup of additional $numBytes exceeds quota of $quota with $newSize."
|
||||||
)
|
)
|
||||||
return TRANSPORT_QUOTA_EXCEEDED
|
return TRANSPORT_QUOTA_EXCEEDED
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
// get output stream or initialize it, if it does not yet exist
|
|
||||||
check((state.outputStream != null) xor (state.outputStreamInit != null)) {
|
|
||||||
"No OutputStream xor no StreamGetter"
|
|
||||||
}
|
|
||||||
val outputStream = state.outputStream ?: suspend {
|
|
||||||
val stream = state.outputStreamInit!!() // not-null due to check above
|
|
||||||
state.outputStream = stream
|
|
||||||
stream
|
|
||||||
}()
|
|
||||||
state.outputStreamInit = null // the stream init lambda is not needed beyond that point
|
|
||||||
|
|
||||||
// read backup data and write it to encrypted output stream
|
// read backup data and write it to encrypted output stream
|
||||||
val payload = ByteArray(numBytes)
|
val payload = ByteArray(numBytes)
|
||||||
val read = state.inputStream.read(payload, 0, numBytes)
|
val read = state.inputStream.read(payload, 0, numBytes)
|
||||||
if (read != numBytes) throw EOFException("Read $read bytes instead of $numBytes.")
|
if (read != numBytes) throw EOFException("Read $read bytes instead of $numBytes.")
|
||||||
outputStream.write(payload)
|
backupReceiver.addBytes(getOwner(state.packageName), payload)
|
||||||
|
state.size += numBytes
|
||||||
TRANSPORT_OK
|
TRANSPORT_OK
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error handling backup data for ${state.packageName}: ", e)
|
Log.e(TAG, "Error handling backup data for ${state.packageName}: ", e)
|
||||||
|
@ -184,45 +135,45 @@ internal class FullBackup(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
suspend fun cancelFullBackup() {
|
||||||
suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) {
|
val state = this.state ?: error("No state when canceling")
|
||||||
val name = crypto.getNameForPackage(salt, packageInfo.packageName)
|
Log.i(TAG, "Cancel full backup for ${state.packageName}")
|
||||||
backend.remove(LegacyAppBackupFile.Blob(token, name))
|
// finalize the receiver
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun cancelFullBackup(token: Long, salt: String, ignoreApp: Boolean) {
|
|
||||||
Log.i(TAG, "Cancel full backup")
|
|
||||||
val state = this.state ?: throw AssertionError("No state when canceling")
|
|
||||||
try {
|
try {
|
||||||
if (!ignoreApp) clearBackupData(state.packageInfo, token, salt)
|
backupReceiver.finalize(getOwner(state.packageName))
|
||||||
} catch (e: IOException) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error cancelling full backup for ${state.packageName}", e)
|
// as the backup was cancelled anyway, we don't care if finalizing had an error
|
||||||
|
Log.e(TAG, "Error finalizing backup in cancelFullBackup().", e)
|
||||||
}
|
}
|
||||||
|
// If the transport receives this callback, it will *not* receive a call to [finishBackup].
|
||||||
|
// It needs to tear down any ongoing backup state here.
|
||||||
clearState()
|
clearState()
|
||||||
// TODO roll back to the previous known-good archive
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun finishBackup(): Int {
|
/**
|
||||||
Log.i(TAG, "Finish full backup of ${state!!.packageName}. Wrote ${state!!.size} bytes")
|
* Returns a pair of the [BackupData] after finalizing last chunks and the total backup size.
|
||||||
return clearState()
|
*/
|
||||||
}
|
@Throws(IOException::class)
|
||||||
|
suspend fun finishBackup(): BackupData {
|
||||||
private fun clearState(): Int {
|
val state = this.state ?: error("No state when finishing")
|
||||||
val state = this.state ?: throw AssertionError("Trying to clear empty state.")
|
Log.i(TAG, "Finish full backup of ${state.packageName}. Wrote ${state.size} bytes")
|
||||||
return try {
|
val result = try {
|
||||||
state.outputStream?.flush()
|
backupReceiver.finalize(getOwner(state.packageName))
|
||||||
closeLogging(state.outputStream)
|
|
||||||
closeLogging(state.inputStream)
|
|
||||||
closeLogging(state.inputFileDescriptor)
|
|
||||||
TRANSPORT_OK
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.w(TAG, "Error when clearing state", e)
|
|
||||||
TRANSPORT_ERROR
|
|
||||||
} finally {
|
} finally {
|
||||||
this.state = null
|
clearState()
|
||||||
}
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearState() {
|
||||||
|
val state = this.state ?: error("Trying to clear empty state.")
|
||||||
|
closeLogging(state.inputStream)
|
||||||
|
closeLogging(state.inputFileDescriptor)
|
||||||
|
this.state = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOwner(packageName: String) = "FullBackup $packageName"
|
||||||
|
|
||||||
private fun closeLogging(closable: Closeable?) = try {
|
private fun closeLogging(closable: Closeable?) = try {
|
||||||
closable?.close()
|
closable?.close()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
@ -14,122 +14,81 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.repo.BackupData
|
||||||
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
import com.stevesoltys.seedvault.repo.BackupReceiver
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
|
||||||
import com.stevesoltys.seedvault.header.getADForKV
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.zip.GZIPOutputStream
|
|
||||||
|
|
||||||
class KVBackupState(
|
class KVBackupState(
|
||||||
internal val packageInfo: PackageInfo,
|
internal val packageInfo: PackageInfo,
|
||||||
val token: Long,
|
|
||||||
val name: String,
|
|
||||||
val db: KVDb,
|
val db: KVDb,
|
||||||
) {
|
)
|
||||||
var needsUpload: Boolean = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
|
|
||||||
|
|
||||||
private val TAG = KVBackup::class.java.simpleName
|
private val TAG = KVBackup::class.java.simpleName
|
||||||
|
|
||||||
internal class KVBackup(
|
internal class KVBackup(
|
||||||
private val backendManager: BackendManager,
|
private val backupReceiver: BackupReceiver,
|
||||||
private val settingsManager: SettingsManager,
|
|
||||||
private val nm: BackupNotificationManager,
|
|
||||||
private val inputFactory: InputFactory,
|
private val inputFactory: InputFactory,
|
||||||
private val crypto: Crypto,
|
|
||||||
private val dbManager: KvDbManager,
|
private val dbManager: KvDbManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val backend get() = backendManager.backend
|
|
||||||
private var state: KVBackupState? = null
|
private var state: KVBackupState? = null
|
||||||
|
|
||||||
fun hasState() = state != null
|
val hasState get() = state != null
|
||||||
|
val currentPackageInfo get() = state?.packageInfo
|
||||||
|
|
||||||
fun getCurrentPackage() = state?.packageInfo
|
fun performBackup(
|
||||||
|
|
||||||
fun getCurrentSize() = getCurrentPackage()?.let {
|
|
||||||
dbManager.getDbSize(it.packageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getQuota(): Long = if (settingsManager.isQuotaUnlimited()) {
|
|
||||||
Long.MAX_VALUE
|
|
||||||
} else {
|
|
||||||
DEFAULT_QUOTA_KEY_VALUE_BACKUP
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun performBackup(
|
|
||||||
packageInfo: PackageInfo,
|
packageInfo: PackageInfo,
|
||||||
data: ParcelFileDescriptor,
|
data: ParcelFileDescriptor,
|
||||||
flags: Int,
|
flags: Int,
|
||||||
token: Long,
|
|
||||||
salt: String,
|
|
||||||
): Int {
|
): Int {
|
||||||
val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0
|
val dataNotChanged = flags and FLAG_DATA_NOT_CHANGED != 0
|
||||||
val isIncremental = flags and FLAG_INCREMENTAL != 0
|
val isIncremental = flags and FLAG_INCREMENTAL != 0
|
||||||
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
|
val isNonIncremental = flags and FLAG_NON_INCREMENTAL != 0
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
|
|
||||||
when {
|
when {
|
||||||
dataNotChanged -> {
|
dataNotChanged -> Log.i(TAG, "No K/V backup data has changed for $packageName")
|
||||||
Log.i(TAG, "No K/V backup data has changed for $packageName")
|
isIncremental -> Log.i(TAG, "Performing incremental K/V backup for $packageName")
|
||||||
}
|
isNonIncremental -> Log.i(TAG, "Performing non-incremental K/V backup for $packageName")
|
||||||
isIncremental -> {
|
else -> Log.i(TAG, "Performing K/V backup for $packageName")
|
||||||
Log.i(TAG, "Performing incremental K/V backup for $packageName")
|
|
||||||
}
|
|
||||||
isNonIncremental -> {
|
|
||||||
Log.i(TAG, "Performing non-incremental K/V backup for $packageName")
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
Log.i(TAG, "Performing K/V backup for $packageName")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
check(state == null) { "Have unexpected state for ${state?.packageInfo?.packageName}" }
|
||||||
|
// This fake package name just signals that we've seen all packages without new data
|
||||||
|
if (packageName == NO_DATA_END_SENTINEL) return TRANSPORT_OK
|
||||||
|
|
||||||
// initialize state
|
// initialize state
|
||||||
val state = this.state
|
state = KVBackupState(packageInfo = packageInfo, db = dbManager.getDb(packageName))
|
||||||
if (state != null) {
|
|
||||||
throw AssertionError("Have state for ${state.packageInfo.packageName}")
|
|
||||||
}
|
|
||||||
val name = crypto.getNameForPackage(salt, packageName)
|
|
||||||
val db = dbManager.getDb(packageName)
|
|
||||||
this.state = KVBackupState(packageInfo, token, name, db)
|
|
||||||
|
|
||||||
// no need for backup when no data has changed
|
// handle case where data hasn't changed since last backup
|
||||||
|
val hasDataForPackage = dbManager.existsDb(packageName)
|
||||||
if (dataNotChanged) {
|
if (dataNotChanged) {
|
||||||
data.close()
|
data.close()
|
||||||
return TRANSPORT_OK
|
return if (hasDataForPackage) {
|
||||||
|
TRANSPORT_OK
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "No previous data for $packageName, requesting non-incremental backup!")
|
||||||
|
backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if we have existing data for the given package
|
// check if we have existing data for the given package
|
||||||
val hasDataForPackage = dbManager.existsDb(packageName)
|
|
||||||
if (isIncremental && !hasDataForPackage) {
|
if (isIncremental && !hasDataForPackage) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG, "Requested incremental, but transport currently stores no data" +
|
TAG, "Requested incremental, but transport currently stores no data" +
|
||||||
" for $packageName, requesting non-incremental retry."
|
" for $packageName, requesting non-incremental retry."
|
||||||
)
|
)
|
||||||
|
data.close()
|
||||||
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
|
return backupError(TRANSPORT_NON_INCREMENTAL_BACKUP_REQUIRED)
|
||||||
}
|
}
|
||||||
|
// check if we have existing data, but the system wants clean slate
|
||||||
// TODO check if package is over-quota and respect unlimited setting
|
|
||||||
|
|
||||||
if (isNonIncremental && hasDataForPackage) {
|
if (isNonIncremental && hasDataForPackage) {
|
||||||
Log.w(TAG, "Requested non-incremental, deleting existing data.")
|
Log.w(TAG, "Requested non-incremental, deleting existing data...")
|
||||||
try {
|
dbManager.deleteDb(packageInfo.packageName)
|
||||||
clearBackupData(packageInfo, token, salt)
|
// KvBackupInstrumentationTest tells us that the DB gets re-created automatically
|
||||||
} catch (e: IOException) {
|
|
||||||
Log.w(TAG, "Error clearing backup data for ${packageInfo.packageName}.", e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse and store the K/V updates
|
// parse and store the K/V updates
|
||||||
return storeRecords(data)
|
return data.use {
|
||||||
|
storeRecords(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun storeRecords(data: ParcelFileDescriptor): Int {
|
private fun storeRecords(data: ParcelFileDescriptor): Int {
|
||||||
|
@ -140,18 +99,6 @@ internal class KVBackup(
|
||||||
Log.e(TAG, "Exception reading backup input", result.exception)
|
Log.e(TAG, "Exception reading backup input", result.exception)
|
||||||
return backupError(TRANSPORT_ERROR)
|
return backupError(TRANSPORT_ERROR)
|
||||||
}
|
}
|
||||||
state.needsUpload = if (state.packageInfo.packageName == MAGIC_PACKAGE_MANAGER) {
|
|
||||||
// Don't upload, if we currently can't do backups.
|
|
||||||
// If we tried, we would fail @pm@ backup which causes the system to do a re-init.
|
|
||||||
// See: https://github.com/seedvault-app/seedvault/issues/102
|
|
||||||
// K/V backups (typically starting with package manager metadata - @pm@)
|
|
||||||
// are scheduled with JobInfo.Builder#setOverrideDeadline()
|
|
||||||
// and thus do not respect backoff.
|
|
||||||
backendManager.canDoBackupNow()
|
|
||||||
} else {
|
|
||||||
// all other packages always need upload
|
|
||||||
true
|
|
||||||
}
|
|
||||||
val op = (result as Result.Ok).result
|
val op = (result as Result.Ok).result
|
||||||
if (op.value == null) {
|
if (op.value == null) {
|
||||||
Log.e(TAG, "Deleting record with key ${op.key}")
|
Log.e(TAG, "Deleting record with key ${op.key}")
|
||||||
|
@ -205,27 +152,21 @@ internal class KVBackup(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
suspend fun clearBackupData(packageInfo: PackageInfo, token: Long, salt: String) {
|
suspend fun finishBackup(): BackupData {
|
||||||
Log.i(TAG, "Clearing K/V data of ${packageInfo.packageName}")
|
|
||||||
val name = state?.name ?: crypto.getNameForPackage(salt, packageInfo.packageName)
|
|
||||||
backend.remove(LegacyAppBackupFile.Blob(token, name))
|
|
||||||
if (!dbManager.deleteDb(packageInfo.packageName)) throw IOException()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun finishBackup(): Int {
|
|
||||||
val state = this.state ?: error("No state in finishBackup")
|
val state = this.state ?: error("No state in finishBackup")
|
||||||
val packageName = state.packageInfo.packageName
|
val packageName = state.packageInfo.packageName
|
||||||
Log.i(TAG, "Finish K/V Backup of $packageName - needs upload: ${state.needsUpload}")
|
val owner = "KV $packageName"
|
||||||
|
Log.i(TAG, "Finish K/V Backup of $packageName")
|
||||||
|
|
||||||
return try {
|
try {
|
||||||
if (state.needsUpload) uploadDb(state.token, state.name, packageName, state.db)
|
state.db.vacuum()
|
||||||
else state.db.close()
|
state.db.close()
|
||||||
TRANSPORT_OK
|
val backupData = dbManager.getDbInputStream(packageName).use { inputStream ->
|
||||||
} catch (e: IOException) {
|
backupReceiver.readFromStream(owner, inputStream)
|
||||||
Log.e(TAG, "Error uploading DB", e)
|
}
|
||||||
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
Log.d(TAG, "Uploaded db file for $packageName.")
|
||||||
TRANSPORT_ERROR
|
return backupData
|
||||||
} finally {
|
} finally { // exceptions bubble up
|
||||||
this.state = null
|
this.state = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -240,36 +181,10 @@ internal class KVBackup(
|
||||||
Log.i(TAG, "Resetting state because of K/V Backup error of $packageName")
|
Log.i(TAG, "Resetting state because of K/V Backup error of $packageName")
|
||||||
|
|
||||||
state.db.close()
|
state.db.close()
|
||||||
|
|
||||||
this.state = null
|
this.state = null
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private suspend fun uploadDb(
|
|
||||||
token: Long,
|
|
||||||
name: String,
|
|
||||||
packageName: String,
|
|
||||||
db: KVDb,
|
|
||||||
) {
|
|
||||||
db.vacuum()
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
val handle = LegacyAppBackupFile.Blob(token, name)
|
|
||||||
backend.save(handle).use { outputStream ->
|
|
||||||
outputStream.write(ByteArray(1) { VERSION })
|
|
||||||
val ad = getADForKV(VERSION, packageName)
|
|
||||||
crypto.newEncryptingStream(outputStream, ad).use { encryptedStream ->
|
|
||||||
GZIPOutputStream(encryptedStream).use { gZipStream ->
|
|
||||||
dbManager.getDbInputStream(packageName).use { inputStream ->
|
|
||||||
inputStream.copyTo(gZipStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.d(TAG, "Uploaded db file for $packageName.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private class KVOperation(
|
private class KVOperation(
|
||||||
val key: String,
|
val key: String,
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -5,12 +5,10 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.transport.backup
|
package com.stevesoltys.seedvault.transport.backup
|
||||||
|
|
||||||
import android.app.backup.IBackupManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.Intent.ACTION_MAIN
|
import android.content.Intent.ACTION_MAIN
|
||||||
import android.content.Intent.CATEGORY_LAUNCHER
|
import android.content.Intent.CATEGORY_LAUNCHER
|
||||||
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
|
|
||||||
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
||||||
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
||||||
import android.content.pm.ApplicationInfo.FLAG_TEST_ONLY
|
import android.content.pm.ApplicationInfo.FLAG_TEST_ONLY
|
||||||
|
@ -22,7 +20,6 @@ import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||||
import android.content.pm.PackageManager.MATCH_SYSTEM_ONLY
|
import android.content.pm.PackageManager.MATCH_SYSTEM_ONLY
|
||||||
import android.content.pm.ResolveInfo
|
import android.content.pm.ResolveInfo
|
||||||
import android.os.RemoteException
|
import android.os.RemoteException
|
||||||
import android.os.UserHandle
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Log.INFO
|
import android.util.Log.INFO
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
|
@ -41,13 +38,11 @@ private const val LOG_MAX_PACKAGES = 100
|
||||||
*/
|
*/
|
||||||
internal class PackageService(
|
internal class PackageService(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val backupManager: IBackupManager,
|
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val backendManager: BackendManager,
|
private val backendManager: BackendManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val packageManager: PackageManager = context.packageManager
|
private val packageManager: PackageManager = context.packageManager
|
||||||
private val myUserId = UserHandle.myUserId()
|
|
||||||
private val backend: Backend get() = backendManager.backend
|
private val backend: Backend get() = backendManager.backend
|
||||||
|
|
||||||
val eligiblePackages: List<String>
|
val eligiblePackages: List<String>
|
||||||
|
@ -64,25 +59,17 @@ internal class PackageService(
|
||||||
logPackages(packages)
|
logPackages(packages)
|
||||||
}
|
}
|
||||||
|
|
||||||
val eligibleApps = if (settingsManager.d2dBackupsEnabled()) {
|
val eligibleApps = packages.filter(::shouldIncludeAppInBackup).toMutableList()
|
||||||
// if D2D is enabled, use the "new method" for filtering packages
|
|
||||||
packages.filter(::shouldIncludeAppInBackup).toTypedArray()
|
|
||||||
} else {
|
|
||||||
// otherwise, use the BackupManager call.
|
|
||||||
backupManager.filterAppsEligibleForBackupForUser(myUserId, packages.toTypedArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
// log eligible packages
|
// log eligible packages
|
||||||
if (Log.isLoggable(TAG, INFO)) {
|
if (Log.isLoggable(TAG, INFO)) {
|
||||||
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:")
|
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:")
|
||||||
logPackages(eligibleApps.toList())
|
logPackages(eligibleApps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
|
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
|
||||||
val packageArray = eligibleApps.toMutableList()
|
eligibleApps.add(0, MAGIC_PACKAGE_MANAGER)
|
||||||
packageArray.add(MAGIC_PACKAGE_MANAGER)
|
|
||||||
|
|
||||||
return packageArray
|
return eligibleApps
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -137,21 +124,6 @@ internal class PackageService(
|
||||||
packageInfo.allowsBackup()
|
packageInfo.allowsBackup()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of apps that do not allow backup.
|
|
||||||
*/
|
|
||||||
val userNotAllowedApps: List<PackageInfo>
|
|
||||||
@WorkerThread
|
|
||||||
get() {
|
|
||||||
// if D2D backups are enabled, all apps are allowed
|
|
||||||
if (settingsManager.d2dBackupsEnabled()) return emptyList()
|
|
||||||
|
|
||||||
return packageManager.getInstalledPackages(0).filter { packageInfo ->
|
|
||||||
!packageInfo.allowsBackup() &&
|
|
||||||
!packageInfo.isSystemApp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val launchableSystemApps: List<ResolveInfo>
|
val launchableSystemApps: List<ResolveInfo>
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
get() {
|
get() {
|
||||||
|
@ -196,26 +168,20 @@ internal class PackageService(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun PackageInfo.allowsBackup(): Boolean {
|
private fun PackageInfo.allowsBackup(): Boolean {
|
||||||
|
/**
|
||||||
|
* TODO: Consider ways of replicating the system's logic so that the user can have
|
||||||
|
* advance knowledge of apps that the system will exclude, particularly apps targeting
|
||||||
|
* SDK 30 or below.
|
||||||
|
*
|
||||||
|
* At backup time, the system will filter out any apps that *it* does not want to be
|
||||||
|
* backed up. If the user has enabled D2D, *we* generally want to back up as much as
|
||||||
|
* possible; part of the point of D2D is to ignore FLAG_ALLOW_BACKUP (allowsBackup).
|
||||||
|
* So, we return true.
|
||||||
|
* See frameworks/base/services/backup/java/com/android/server/backup/utils/
|
||||||
|
* BackupEligibilityRules.java lines 74-81 and 163-167 (tag: android-13.0.0_r8).
|
||||||
|
*/
|
||||||
val appInfo = applicationInfo
|
val appInfo = applicationInfo
|
||||||
if (packageName == MAGIC_PACKAGE_MANAGER || appInfo == null) return false
|
return !(packageName == MAGIC_PACKAGE_MANAGER || appInfo == null)
|
||||||
|
|
||||||
return if (settingsManager.d2dBackupsEnabled()) {
|
|
||||||
/**
|
|
||||||
* TODO: Consider ways of replicating the system's logic so that the user can have
|
|
||||||
* advance knowledge of apps that the system will exclude, particularly apps targeting
|
|
||||||
* SDK 30 or below.
|
|
||||||
*
|
|
||||||
* At backup time, the system will filter out any apps that *it* does not want to be
|
|
||||||
* backed up. If the user has enabled D2D, *we* generally want to back up as much as
|
|
||||||
* possible; part of the point of D2D is to ignore FLAG_ALLOW_BACKUP (allowsBackup).
|
|
||||||
* So, we return true.
|
|
||||||
* See frameworks/base/services/backup/java/com/android/server/backup/utils/
|
|
||||||
* BackupEligibilityRules.java lines 74-81 and 163-167 (tag: android-13.0.0_r8).
|
|
||||||
*/
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
appInfo.flags and FLAG_ALLOW_BACKUP != 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -12,14 +12,16 @@ import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
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.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.UnsupportedVersionException
|
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
import com.stevesoltys.seedvault.header.getADForFull
|
import com.stevesoltys.seedvault.header.getADForFull
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.repo.Loader
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
|
||||||
import libcore.io.IoUtils.closeQuietly
|
import libcore.io.IoUtils.closeQuietly
|
||||||
|
import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||||
import java.io.EOFException
|
import java.io.EOFException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
@ -29,9 +31,10 @@ import java.security.GeneralSecurityException
|
||||||
|
|
||||||
private class FullRestoreState(
|
private class FullRestoreState(
|
||||||
val version: Byte,
|
val version: Byte,
|
||||||
val token: Long,
|
|
||||||
val name: String,
|
|
||||||
val packageInfo: PackageInfo,
|
val packageInfo: PackageInfo,
|
||||||
|
val blobHandles: List<Blob>? = null,
|
||||||
|
val token: Long? = null,
|
||||||
|
val name: String? = null,
|
||||||
) {
|
) {
|
||||||
var inputStream: InputStream? = null
|
var inputStream: InputStream? = null
|
||||||
}
|
}
|
||||||
|
@ -40,6 +43,7 @@ private val TAG = FullRestore::class.java.simpleName
|
||||||
|
|
||||||
internal class FullRestore(
|
internal class FullRestore(
|
||||||
private val backendManager: BackendManager,
|
private val backendManager: BackendManager,
|
||||||
|
private val loader: Loader,
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyPlugin: LegacyStoragePlugin,
|
private val legacyPlugin: LegacyStoragePlugin,
|
||||||
private val outputFactory: OutputFactory,
|
private val outputFactory: OutputFactory,
|
||||||
|
@ -50,7 +54,7 @@ internal class FullRestore(
|
||||||
private val backend get() = backendManager.backend
|
private val backend get() = backendManager.backend
|
||||||
private var state: FullRestoreState? = null
|
private var state: FullRestoreState? = null
|
||||||
|
|
||||||
fun hasState() = state != null
|
val hasState get() = state != null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return true if there is data stored for the given package.
|
* Return true if there is data stored for the given package.
|
||||||
|
@ -69,8 +73,16 @@ internal class FullRestore(
|
||||||
* It is possible that the system decides to not restore the package.
|
* It is possible that the system decides to not restore the package.
|
||||||
* Then a new state will be initialized right away without calling other methods.
|
* Then a new state will be initialized right away without calling other methods.
|
||||||
*/
|
*/
|
||||||
fun initializeState(version: Byte, token: Long, name: String, packageInfo: PackageInfo) {
|
fun initializeState(version: Byte, packageInfo: PackageInfo, blobHandles: List<Blob>) {
|
||||||
state = FullRestoreState(version, token, name, packageInfo)
|
state = FullRestoreState(version, packageInfo, blobHandles)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initializeStateV1(token: Long, name: String, packageInfo: PackageInfo) {
|
||||||
|
state = FullRestoreState(1, packageInfo, null, token, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initializeStateV0(token: Long, packageInfo: PackageInfo) {
|
||||||
|
state = FullRestoreState(0x00, packageInfo, null, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -107,19 +119,29 @@ internal class FullRestore(
|
||||||
if (state.inputStream == null) {
|
if (state.inputStream == null) {
|
||||||
Log.i(TAG, "First Chunk, initializing package input stream.")
|
Log.i(TAG, "First Chunk, initializing package input stream.")
|
||||||
try {
|
try {
|
||||||
if (state.version == 0.toByte()) {
|
when (state.version) {
|
||||||
val inputStream =
|
0.toByte() -> {
|
||||||
legacyPlugin.getInputStreamForPackage(state.token, state.packageInfo)
|
val token = state.token ?: error("no token for v0 backup")
|
||||||
val version = headerReader.readVersion(inputStream, state.version)
|
val inputStream =
|
||||||
@Suppress("deprecation")
|
legacyPlugin.getInputStreamForPackage(token, state.packageInfo)
|
||||||
crypto.decryptHeader(inputStream, version, packageName)
|
val version = headerReader.readVersion(inputStream, state.version)
|
||||||
state.inputStream = inputStream
|
@Suppress("deprecation")
|
||||||
} else {
|
crypto.decryptHeader(inputStream, version, packageName)
|
||||||
val handle = LegacyAppBackupFile.Blob(state.token, state.name)
|
state.inputStream = inputStream
|
||||||
val inputStream = backend.load(handle)
|
}
|
||||||
val version = headerReader.readVersion(inputStream, state.version)
|
1.toByte() -> {
|
||||||
val ad = getADForFull(version, packageName)
|
val token = state.token ?: error("no token for v1 backup")
|
||||||
state.inputStream = crypto.newDecryptingStream(inputStream, ad)
|
val name = state.name ?: error("no name for v1 backup")
|
||||||
|
val handle = LegacyAppBackupFile.Blob(token, name)
|
||||||
|
val inputStream = backend.load(handle)
|
||||||
|
val version = headerReader.readVersion(inputStream, state.version)
|
||||||
|
val ad = getADForFull(version, packageName)
|
||||||
|
state.inputStream = crypto.newDecryptingStreamV1(inputStream, ad)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val handles = state.blobHandles ?: error("no blob handles for v2")
|
||||||
|
state.inputStream = loader.loadFiles(handles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.w(TAG, "Error getting input stream for $packageName", e)
|
Log.w(TAG, "Error getting input stream for $packageName", e)
|
||||||
|
|
|
@ -14,17 +14,18 @@ import android.util.Log
|
||||||
import com.stevesoltys.seedvault.ANCESTRAL_RECORD_KEY
|
import com.stevesoltys.seedvault.ANCESTRAL_RECORD_KEY
|
||||||
import com.stevesoltys.seedvault.GLOBAL_METADATA_KEY
|
import com.stevesoltys.seedvault.GLOBAL_METADATA_KEY
|
||||||
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.decodeBase64
|
import com.stevesoltys.seedvault.decodeBase64
|
||||||
import com.stevesoltys.seedvault.header.HeaderReader
|
import com.stevesoltys.seedvault.header.HeaderReader
|
||||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
|
||||||
import com.stevesoltys.seedvault.header.getADForKV
|
import com.stevesoltys.seedvault.header.getADForKV
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.repo.Loader
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.transport.backup.KVDb
|
import com.stevesoltys.seedvault.transport.backup.KVDb
|
||||||
import com.stevesoltys.seedvault.transport.backup.KvDbManager
|
import com.stevesoltys.seedvault.transport.backup.KvDbManager
|
||||||
import libcore.io.IoUtils.closeQuietly
|
import libcore.io.IoUtils.closeQuietly
|
||||||
|
import org.calyxos.seedvault.core.backends.AppBackupFileType.Blob
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.security.GeneralSecurityException
|
import java.security.GeneralSecurityException
|
||||||
|
@ -33,19 +34,21 @@ import javax.crypto.AEADBadTagException
|
||||||
|
|
||||||
private class KVRestoreState(
|
private class KVRestoreState(
|
||||||
val version: Byte,
|
val version: Byte,
|
||||||
val token: Long,
|
|
||||||
val name: String,
|
|
||||||
val packageInfo: PackageInfo,
|
val packageInfo: PackageInfo,
|
||||||
|
val blobHandles: List<Blob>? = null,
|
||||||
|
val token: Long? = null,
|
||||||
|
val name: String? = null,
|
||||||
/**
|
/**
|
||||||
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@
|
* Optional [PackageInfo] for single package restore, optimizes restore of @pm@
|
||||||
*/
|
*/
|
||||||
val autoRestorePackageInfo: PackageInfo?,
|
val autoRestorePackageInfo: PackageInfo? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val TAG = KVRestore::class.java.simpleName
|
private val TAG = KVRestore::class.java.simpleName
|
||||||
|
|
||||||
internal class KVRestore(
|
internal class KVRestore(
|
||||||
private val backendManager: BackendManager,
|
private val backendManager: BackendManager,
|
||||||
|
private val loader: Loader,
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyPlugin: LegacyStoragePlugin,
|
private val legacyPlugin: LegacyStoragePlugin,
|
||||||
private val outputFactory: OutputFactory,
|
private val outputFactory: OutputFactory,
|
||||||
|
@ -78,12 +81,32 @@ internal class KVRestore(
|
||||||
*/
|
*/
|
||||||
fun initializeState(
|
fun initializeState(
|
||||||
version: Byte,
|
version: Byte,
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
blobHandles: List<Blob>,
|
||||||
|
autoRestorePackageInfo: PackageInfo? = null,
|
||||||
|
) {
|
||||||
|
state = KVRestoreState(
|
||||||
|
version = version,
|
||||||
|
packageInfo = packageInfo,
|
||||||
|
blobHandles = blobHandles,
|
||||||
|
autoRestorePackageInfo = autoRestorePackageInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initializeStateV1(
|
||||||
token: Long,
|
token: Long,
|
||||||
name: String,
|
name: String,
|
||||||
packageInfo: PackageInfo,
|
packageInfo: PackageInfo,
|
||||||
autoRestorePackageInfo: PackageInfo? = null,
|
autoRestorePackageInfo: PackageInfo? = null,
|
||||||
) {
|
) {
|
||||||
state = KVRestoreState(version, token, name, packageInfo, autoRestorePackageInfo)
|
state = KVRestoreState(1, packageInfo, null, token, name, autoRestorePackageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initializeStateV0(
|
||||||
|
token: Long,
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
) {
|
||||||
|
state = KVRestoreState(0x00, packageInfo, null, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -106,7 +129,8 @@ internal class KVRestore(
|
||||||
val database = if (isAutoRestore) {
|
val database = if (isAutoRestore) {
|
||||||
getCachedRestoreDb(state)
|
getCachedRestoreDb(state)
|
||||||
} else {
|
} else {
|
||||||
downloadRestoreDb(state)
|
if (state.version == 1.toByte()) downloadRestoreDbV1(state)
|
||||||
|
else downloadRestoreDb(state)
|
||||||
}
|
}
|
||||||
database.use { db ->
|
database.use { db ->
|
||||||
val out = outputFactory.getBackupDataOutput(data)
|
val out = outputFactory.getBackupDataOutput(data)
|
||||||
|
@ -150,18 +174,38 @@ internal class KVRestore(
|
||||||
return if (dbManager.existsDb(packageName)) {
|
return if (dbManager.existsDb(packageName)) {
|
||||||
dbManager.getDb(packageName)
|
dbManager.getDb(packageName)
|
||||||
} else {
|
} else {
|
||||||
downloadRestoreDb(state)
|
if (state.version == 1.toByte()) downloadRestoreDbV1(state)
|
||||||
|
else downloadRestoreDb(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class)
|
@Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class)
|
||||||
private suspend fun downloadRestoreDb(state: KVRestoreState): KVDb {
|
private suspend fun downloadRestoreDb(state: KVRestoreState): KVDb {
|
||||||
val packageName = state.packageInfo.packageName
|
val packageName = state.packageInfo.packageName
|
||||||
val handle = LegacyAppBackupFile.Blob(state.token, state.name)
|
val handles = state.blobHandles ?: error("no blob handles for v2")
|
||||||
|
loader.loadFiles(handles).use { inputStream ->
|
||||||
|
dbManager.getDbOutputStream(packageName).use { outputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbManager.getDb(packageName, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// v1 restore legacy code below
|
||||||
|
//
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
@Throws(IOException::class, GeneralSecurityException::class, UnsupportedVersionException::class)
|
||||||
|
private suspend fun downloadRestoreDbV1(state: KVRestoreState): KVDb {
|
||||||
|
val token = state.token ?: error("No token for v1 restore")
|
||||||
|
val name = state.name ?: error("No name for v1 restore")
|
||||||
|
val packageName = state.packageInfo.packageName
|
||||||
|
val handle = LegacyAppBackupFile.Blob(token, name)
|
||||||
backend.load(handle).use { inputStream ->
|
backend.load(handle).use { inputStream ->
|
||||||
headerReader.readVersion(inputStream, state.version)
|
headerReader.readVersion(inputStream, state.version)
|
||||||
val ad = getADForKV(VERSION, packageName)
|
val ad = getADForKV(state.version, packageName)
|
||||||
crypto.newDecryptingStream(inputStream, ad).use { decryptedStream ->
|
crypto.newDecryptingStreamV1(inputStream, ad).use { decryptedStream ->
|
||||||
GZIPInputStream(decryptedStream).use { gzipStream ->
|
GZIPInputStream(decryptedStream).use { gzipStream ->
|
||||||
dbManager.getDbOutputStream(packageName).use { outputStream ->
|
dbManager.getDbOutputStream(packageName).use { outputStream ->
|
||||||
gzipStream.copyTo(outputStream)
|
gzipStream.copyTo(outputStream)
|
||||||
|
@ -182,7 +226,8 @@ internal class KVRestore(
|
||||||
// We return the data in lexical order sorted by key,
|
// We return the data in lexical order sorted by key,
|
||||||
// so that apps which use synthetic keys like BLOB_1, BLOB_2, etc
|
// so that apps which use synthetic keys like BLOB_1, BLOB_2, etc
|
||||||
// will see the date in the most obvious order.
|
// will see the date in the most obvious order.
|
||||||
val sortedKeys = getSortedKeysV0(state.token, state.packageInfo)
|
val token = state.token ?: error("No token for v0 restore")
|
||||||
|
val sortedKeys = getSortedKeysV0(token, state.packageInfo)
|
||||||
if (sortedKeys == null) {
|
if (sortedKeys == null) {
|
||||||
// nextRestorePackage() ensures the dir exists, so this is an error
|
// nextRestorePackage() ensures the dir exists, so this is an error
|
||||||
Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}")
|
Log.e(TAG, "No keys for package: ${state.packageInfo.packageName}")
|
||||||
|
@ -245,7 +290,7 @@ internal class KVRestore(
|
||||||
state: KVRestoreState,
|
state: KVRestoreState,
|
||||||
dKey: DecodedKey,
|
dKey: DecodedKey,
|
||||||
out: BackupDataOutput,
|
out: BackupDataOutput,
|
||||||
) = legacyPlugin.getInputStreamForRecord(state.token, state.packageInfo, dKey.base64Key)
|
) = legacyPlugin.getInputStreamForRecord(state.token!!, state.packageInfo, dKey.base64Key)
|
||||||
.use { inputStream ->
|
.use { inputStream ->
|
||||||
val version = headerReader.readVersion(inputStream, state.version)
|
val version = headerReader.readVersion(inputStream, state.version)
|
||||||
val packageName = state.packageInfo.packageName
|
val packageName = state.packageInfo.packageName
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.stevesoltys.seedvault.transport.restore
|
||||||
|
|
||||||
|
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
|
import com.stevesoltys.seedvault.proto.Snapshot
|
||||||
|
|
||||||
|
sealed class RestorableBackupResult {
|
||||||
|
data class ErrorResult(val e: Exception?) : RestorableBackupResult()
|
||||||
|
data class SuccessResult(val backups: List<RestorableBackup>) : RestorableBackupResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RestorableBackup(
|
||||||
|
val backupMetadata: BackupMetadata,
|
||||||
|
val repoId: String? = null,
|
||||||
|
val snapshot: Snapshot? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(repoId: String, snapshot: Snapshot) : this(
|
||||||
|
backupMetadata = BackupMetadata.fromSnapshot(snapshot),
|
||||||
|
repoId = repoId,
|
||||||
|
snapshot = snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
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() = snapshot?.token ?: backupMetadata.time
|
||||||
|
|
||||||
|
val size: Long = snapshot?.blobsMap?.values?.sumOf { it.uncompressedLength.toLong() }
|
||||||
|
?: backupMetadata.size
|
||||||
|
|
||||||
|
val deviceName: String
|
||||||
|
get() = backupMetadata.deviceName
|
||||||
|
|
||||||
|
val user: String?
|
||||||
|
get() = snapshot?.user?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
val d2dBackup: Boolean
|
||||||
|
get() = backupMetadata.d2dBackup
|
||||||
|
|
||||||
|
val numAppData: Int = snapshot?.appsMap?.values?.count { it.chunkIdsCount > 0 }
|
||||||
|
?: packageMetadataMap.values.count { packageMetadata ->
|
||||||
|
packageMetadata.backupType != null && packageMetadata.state == APK_AND_DATA
|
||||||
|
}
|
||||||
|
|
||||||
|
val sizeAppData: Long = snapshot?.appsMap?.values?.sumOf { it.size }
|
||||||
|
?: packageMetadataMap.values.sumOf { it.size ?: 0L }
|
||||||
|
|
||||||
|
val numApks: Int = snapshot?.appsMap?.values?.count { it.apk.splitsCount > 0 }
|
||||||
|
?: packageMetadataMap.values.count { it.hasApk() }
|
||||||
|
|
||||||
|
val sizeApks: Long = size - sizeAppData
|
||||||
|
|
||||||
|
val packageMetadataMap: PackageMetadataMap
|
||||||
|
get() = backupMetadata.packageMetadataMap
|
||||||
|
|
||||||
|
}
|
|
@ -18,21 +18,24 @@ import android.os.ParcelFileDescriptor
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
import com.stevesoltys.seedvault.metadata.BackupMetadata
|
|
||||||
import com.stevesoltys.seedvault.metadata.BackupType
|
import com.stevesoltys.seedvault.metadata.BackupType
|
||||||
import com.stevesoltys.seedvault.metadata.DecryptionFailedException
|
import com.stevesoltys.seedvault.metadata.DecryptionFailedException
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataReader
|
import com.stevesoltys.seedvault.metadata.MetadataReader
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.repo.SnapshotManager
|
||||||
import com.stevesoltys.seedvault.backend.getAvailableBackups
|
import com.stevesoltys.seedvault.repo.getBlobHandles
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS
|
import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS
|
||||||
import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS
|
import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
|
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import org.calyxos.seedvault.core.backends.Backend
|
||||||
|
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.security.GeneralSecurityException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Device name used in AOSP to indicate that a restore set is part of a device-to-device migration.
|
* Device name used in AOSP to indicate that a restore set is part of a device-to-device migration.
|
||||||
|
@ -49,7 +52,7 @@ private data class RestoreCoordinatorState(
|
||||||
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
|
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
|
||||||
*/
|
*/
|
||||||
val autoRestorePackageInfo: PackageInfo?,
|
val autoRestorePackageInfo: PackageInfo?,
|
||||||
val backupMetadata: BackupMetadata,
|
val backup: RestorableBackup,
|
||||||
) {
|
) {
|
||||||
var currentPackage: String? = null
|
var currentPackage: String? = null
|
||||||
}
|
}
|
||||||
|
@ -63,6 +66,7 @@ internal class RestoreCoordinator(
|
||||||
private val metadataManager: MetadataManager,
|
private val metadataManager: MetadataManager,
|
||||||
private val notificationManager: BackupNotificationManager,
|
private val notificationManager: BackupNotificationManager,
|
||||||
private val backendManager: BackendManager,
|
private val backendManager: BackendManager,
|
||||||
|
private val snapshotManager: SnapshotManager,
|
||||||
private val kv: KVRestore,
|
private val kv: KVRestore,
|
||||||
private val full: FullRestore,
|
private val full: FullRestore,
|
||||||
private val metadataReader: MetadataReader,
|
private val metadataReader: MetadataReader,
|
||||||
|
@ -70,34 +74,58 @@ internal class RestoreCoordinator(
|
||||||
|
|
||||||
private val backend: Backend get() = backendManager.backend
|
private val backend: Backend get() = backendManager.backend
|
||||||
private var state: RestoreCoordinatorState? = null
|
private var state: RestoreCoordinatorState? = null
|
||||||
private var backupMetadata: BackupMetadata? = null
|
private var restorableBackup: RestorableBackup? = null
|
||||||
private val failedPackages = ArrayList<String>()
|
private val failedPackages = ArrayList<String>()
|
||||||
|
|
||||||
suspend fun getAvailableMetadata(): Map<Long, BackupMetadata>? {
|
suspend fun getAvailableBackups(): RestorableBackupResult {
|
||||||
val availableBackups = backend.getAvailableBackups() ?: return null
|
Log.i(TAG, "getAvailableBackups")
|
||||||
val metadataMap = HashMap<Long, BackupMetadata>()
|
val fileHandles = try {
|
||||||
for (encryptedMetadata in availableBackups) {
|
backend.getAvailableBackupFileHandles()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error getting available backups.", e)
|
||||||
|
return RestorableBackupResult.ErrorResult(e)
|
||||||
|
}
|
||||||
|
val backups = ArrayList<RestorableBackup>()
|
||||||
|
var lastException: Exception? = null
|
||||||
|
for (handle in fileHandles) {
|
||||||
try {
|
try {
|
||||||
val metadata = encryptedMetadata.inputStreamRetriever().use { inputStream ->
|
val backup = when (handle) {
|
||||||
metadataReader.readMetadata(inputStream, encryptedMetadata.token)
|
is AppBackupFileType.Snapshot -> RestorableBackup(
|
||||||
|
repoId = handle.repoId,
|
||||||
|
snapshot = snapshotManager.loadSnapshot(handle),
|
||||||
|
)
|
||||||
|
is LegacyAppBackupFile.Metadata -> {
|
||||||
|
val metadata = backend.load(handle).use { inputStream ->
|
||||||
|
metadataReader.readMetadata(inputStream, handle.token)
|
||||||
|
}
|
||||||
|
RestorableBackup(backupMetadata = metadata)
|
||||||
|
}
|
||||||
|
else -> error("Unexpected file handle: $handle")
|
||||||
}
|
}
|
||||||
metadataMap[encryptedMetadata.token] = metadata
|
backups.add(backup)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error while getting restore set ${encryptedMetadata.token}", e)
|
Log.e(TAG, "Error while getting restore set $handle", e)
|
||||||
|
lastException = e
|
||||||
continue
|
continue
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.e(TAG, "Error while getting restore set ${encryptedMetadata.token}", e)
|
Log.e(TAG, "Error while getting restore set $handle", e)
|
||||||
return null
|
return RestorableBackupResult.ErrorResult(e)
|
||||||
|
} catch (e: GeneralSecurityException) {
|
||||||
|
Log.e(TAG, "General security error while decrypting restore set $handle", e)
|
||||||
|
lastException = e
|
||||||
|
continue
|
||||||
} catch (e: DecryptionFailedException) {
|
} catch (e: DecryptionFailedException) {
|
||||||
Log.e(TAG, "Error while decrypting restore set ${encryptedMetadata.token}", e)
|
Log.e(TAG, "Error while decrypting restore set $handle", e)
|
||||||
|
lastException = e
|
||||||
continue
|
continue
|
||||||
} catch (e: UnsupportedVersionException) {
|
} catch (e: UnsupportedVersionException) {
|
||||||
Log.w(TAG, "Backup with unsupported version read", e)
|
Log.w(TAG, "Backup with unsupported version read", e)
|
||||||
|
lastException = e
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.i(TAG, "Got available metadata for tokens: ${metadataMap.keys}")
|
if (backups.isEmpty()) return RestorableBackupResult.ErrorResult(lastException)
|
||||||
return metadataMap
|
return RestorableBackupResult.SuccessResult(backups)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -107,22 +135,22 @@ internal class RestoreCoordinator(
|
||||||
* or null if an error occurred (the attempt should be rescheduled).
|
* or null if an error occurred (the attempt should be rescheduled).
|
||||||
**/
|
**/
|
||||||
suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
|
suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
|
||||||
return getAvailableMetadata()?.map { (_, metadata) ->
|
Log.d(TAG, "getAvailableRestoreSets")
|
||||||
|
val result = getAvailableBackups() as? RestorableBackupResult.SuccessResult ?: return null
|
||||||
val transportFlags = if (metadata.d2dBackup) {
|
val backups = result.backups
|
||||||
|
return backups.map { backup ->
|
||||||
|
val transportFlags = if (backup.d2dBackup) {
|
||||||
D2D_TRANSPORT_FLAGS
|
D2D_TRANSPORT_FLAGS
|
||||||
} else {
|
} else {
|
||||||
DEFAULT_TRANSPORT_FLAGS
|
DEFAULT_TRANSPORT_FLAGS
|
||||||
}
|
}
|
||||||
|
val deviceName = if (backup.d2dBackup) {
|
||||||
val deviceName = if (metadata.d2dBackup) {
|
|
||||||
D2D_DEVICE_NAME
|
D2D_DEVICE_NAME
|
||||||
} else {
|
} else {
|
||||||
metadata.deviceName
|
backup.deviceName
|
||||||
}
|
}
|
||||||
|
RestoreSet(backup.deviceName, deviceName, backup.token, transportFlags)
|
||||||
RestoreSet(metadata.deviceName, deviceName, metadata.token, transportFlags)
|
}.toTypedArray()
|
||||||
}?.toTypedArray()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -133,20 +161,16 @@ internal class RestoreCoordinator(
|
||||||
* or 0 if there is no backup set available corresponding to the current device state.
|
* or 0 if there is no backup set available corresponding to the current device state.
|
||||||
*/
|
*/
|
||||||
fun getCurrentRestoreSet(): Long {
|
fun getCurrentRestoreSet(): Long {
|
||||||
return (settingsManager.getToken() ?: 0L).apply {
|
val token = settingsManager.token ?: 0L
|
||||||
Log.i(TAG, "Got current restore set token: $this")
|
Log.d(TAG, "getCurrentRestoreSet() = $token")
|
||||||
}
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call this before starting the restore as an optimization to prevent re-fetching metadata.
|
* Call this before starting the restore as an optimization to prevent re-fetching metadata.
|
||||||
*/
|
*/
|
||||||
fun beforeStartRestore(backupMetadata: BackupMetadata) {
|
fun beforeStartRestore(restorableBackup: RestorableBackup) {
|
||||||
this.backupMetadata = backupMetadata
|
this.restorableBackup = restorableBackup
|
||||||
|
|
||||||
if (backupMetadata.d2dBackup) {
|
|
||||||
settingsManager.setD2dBackupsEnabled(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -164,10 +188,10 @@ internal class RestoreCoordinator(
|
||||||
*/
|
*/
|
||||||
suspend fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
|
suspend fun startRestore(token: Long, packages: Array<out PackageInfo>): Int {
|
||||||
check(state == null) { "Started new restore with existing state: $state" }
|
check(state == null) { "Started new restore with existing state: $state" }
|
||||||
Log.i(TAG, "Start restore with ${packages.map { info -> info.packageName }}")
|
Log.i(TAG, "Start restore $token with ${packages.map { info -> info.packageName }}")
|
||||||
|
|
||||||
// If there's only one package to restore (Auto Restore feature), add it to the state
|
// If there's only one package to restore (Auto Restore feature), add it to the state
|
||||||
val pmPackageInfo =
|
val autoRestorePackageInfo =
|
||||||
if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
|
if (packages.size == 2 && packages[0].packageName == MAGIC_PACKAGE_MANAGER) {
|
||||||
val pmPackageName = packages[1].packageName
|
val pmPackageName = packages[1].packageName
|
||||||
Log.d(TAG, "Optimize for single package restore of $pmPackageName")
|
Log.d(TAG, "Optimize for single package restore of $pmPackageName")
|
||||||
|
@ -188,13 +212,38 @@ internal class RestoreCoordinator(
|
||||||
packages[1]
|
packages[1]
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
val metadata = if (backupMetadata?.token == token) {
|
val backup = if (restorableBackup?.token == token) {
|
||||||
backupMetadata!! // if token matches, backupMetadata is non-null
|
restorableBackup!! // if token matches, backupMetadata is non-null
|
||||||
} else {
|
} else {
|
||||||
getAvailableMetadata()?.get(token) ?: return TRANSPORT_ERROR
|
if (autoRestorePackageInfo == null) { // no auto-restore
|
||||||
|
Log.e(TAG, "No cached backups, loading all and look for $token")
|
||||||
|
val backup = getAvailableBackups() as? RestorableBackupResult.SuccessResult
|
||||||
|
?: return TRANSPORT_ERROR
|
||||||
|
backup.backups.find { it.token == token } ?: return TRANSPORT_ERROR
|
||||||
|
} else {
|
||||||
|
// this is auto-restore, so we use cache and try hard to find a working restore set
|
||||||
|
Log.i(TAG, "No cached backups, loading all and look for $token")
|
||||||
|
val backups = try {
|
||||||
|
snapshotManager.loadCachedSnapshots().map { snapshot ->
|
||||||
|
RestorableBackup(crypto.repoId, snapshot)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error loading cached snapshots: ", e)
|
||||||
|
(getAvailableBackups() as? RestorableBackupResult.SuccessResult)?.backups
|
||||||
|
?: return TRANSPORT_ERROR
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Found ${backups.size} snapshots.")
|
||||||
|
val autoRestorePackageName = autoRestorePackageInfo.packageName
|
||||||
|
val sortedBackups = backups.sortedByDescending { it.token } // latest first
|
||||||
|
sortedBackups.find { it.token == token } ?: sortedBackups.find {
|
||||||
|
val chunkIds = it.packageMetadataMap[autoRestorePackageName]?.chunkIds
|
||||||
|
// try a backup where our auto restore package has data
|
||||||
|
!chunkIds.isNullOrEmpty()
|
||||||
|
} ?: return TRANSPORT_ERROR
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state = RestoreCoordinatorState(token, packages.iterator(), pmPackageInfo, metadata)
|
state = RestoreCoordinatorState(token, packages.iterator(), autoRestorePackageInfo, backup)
|
||||||
backupMetadata = null
|
restorableBackup = null
|
||||||
failedPackages.clear()
|
failedPackages.clear()
|
||||||
return TRANSPORT_OK
|
return TRANSPORT_OK
|
||||||
}
|
}
|
||||||
|
@ -226,39 +275,93 @@ internal class RestoreCoordinator(
|
||||||
* or null to indicate a transport-level error.
|
* or null to indicate a transport-level error.
|
||||||
*/
|
*/
|
||||||
suspend fun nextRestorePackage(): RestoreDescription? {
|
suspend fun nextRestorePackage(): RestoreDescription? {
|
||||||
Log.i(TAG, "Next restore package!")
|
|
||||||
val state = this.state ?: throw IllegalStateException("no state")
|
val state = this.state ?: throw IllegalStateException("no state")
|
||||||
|
|
||||||
if (!state.packages.hasNext()) return NO_MORE_PACKAGES
|
if (!state.packages.hasNext()) return NO_MORE_PACKAGES
|
||||||
val packageInfo = state.packages.next()
|
val packageInfo = state.packages.next()
|
||||||
val version = state.backupMetadata.version
|
Log.i(TAG, "nextRestorePackage() => ${packageInfo.packageName}")
|
||||||
|
val version = state.backup.version
|
||||||
if (version == 0.toByte()) return nextRestorePackageV0(state, packageInfo)
|
if (version == 0.toByte()) return nextRestorePackageV0(state, packageInfo)
|
||||||
|
if (version == 1.toByte()) return nextRestorePackageV1(state, packageInfo)
|
||||||
|
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
val type = when (state.backupMetadata.packageMetadataMap[packageName]?.backupType) {
|
val repoId = state.backup.repoId ?: error("No repoId in v2 backup")
|
||||||
|
val snapshot = state.backup.snapshot ?: error("No snapshot in v2 backup")
|
||||||
|
val type = when (state.backup.packageMetadataMap[packageName]?.backupType) {
|
||||||
BackupType.KV -> {
|
BackupType.KV -> {
|
||||||
val name = crypto.getNameForPackage(state.backupMetadata.salt, packageName)
|
val blobHandles = try {
|
||||||
|
val chunkIds = state.backup.packageMetadataMap[packageName]?.chunkIds
|
||||||
|
?: error("no metadata or chunkIds")
|
||||||
|
snapshot.getBlobHandles(repoId, chunkIds)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error getting blob handles: ", e)
|
||||||
|
failedPackages.add(packageName)
|
||||||
|
// abort here as this is close to an assertion error
|
||||||
|
return null
|
||||||
|
}
|
||||||
kv.initializeState(
|
kv.initializeState(
|
||||||
version = version,
|
version = version,
|
||||||
token = state.token,
|
|
||||||
name = name,
|
|
||||||
packageInfo = packageInfo,
|
packageInfo = packageInfo,
|
||||||
autoRestorePackageInfo = state.autoRestorePackageInfo
|
blobHandles = blobHandles,
|
||||||
|
autoRestorePackageInfo = state.autoRestorePackageInfo,
|
||||||
)
|
)
|
||||||
state.currentPackage = packageName
|
state.currentPackage = packageName
|
||||||
TYPE_KEY_VALUE
|
TYPE_KEY_VALUE
|
||||||
}
|
}
|
||||||
|
|
||||||
BackupType.FULL -> {
|
BackupType.FULL -> {
|
||||||
val name = crypto.getNameForPackage(state.backupMetadata.salt, packageName)
|
val blobHandles = try {
|
||||||
full.initializeState(version, state.token, name, packageInfo)
|
val chunkIds = state.backup.packageMetadataMap[packageName]?.chunkIds
|
||||||
|
?: error("no metadata or chunkIds")
|
||||||
|
snapshot.getBlobHandles(repoId, chunkIds)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error getting blob handles: ", e)
|
||||||
|
failedPackages.add(packageName)
|
||||||
|
// abort here as this is close to an assertion error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
full.initializeState(version, packageInfo, blobHandles)
|
||||||
state.currentPackage = packageName
|
state.currentPackage = packageName
|
||||||
TYPE_FULL_STREAM
|
TYPE_FULL_STREAM
|
||||||
}
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Log.i(TAG, "No backup type found for $packageName. Skipping...")
|
Log.i(TAG, "No backup type found for $packageName. Skipping...")
|
||||||
state.backupMetadata.packageMetadataMap[packageName]?.backupType?.let { s ->
|
state.backup.packageMetadataMap[packageName]?.backupType?.let { s ->
|
||||||
|
Log.w(TAG, "State was ${s.name}")
|
||||||
|
}
|
||||||
|
failedPackages.add(packageName)
|
||||||
|
// don't return null and cause abort here, but try next package
|
||||||
|
return nextRestorePackage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RestoreDescription(packageName, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("deprecation")
|
||||||
|
private suspend fun nextRestorePackageV1(
|
||||||
|
state: RestoreCoordinatorState,
|
||||||
|
packageInfo: PackageInfo,
|
||||||
|
): RestoreDescription? {
|
||||||
|
val packageName = packageInfo.packageName
|
||||||
|
val type = when (state.backup.packageMetadataMap[packageName]?.backupType) {
|
||||||
|
BackupType.KV -> {
|
||||||
|
kv.initializeStateV1(
|
||||||
|
token = state.token,
|
||||||
|
name = crypto.getNameForPackage(state.backup.salt, packageName),
|
||||||
|
packageInfo = packageInfo,
|
||||||
|
autoRestorePackageInfo = state.autoRestorePackageInfo,
|
||||||
|
)
|
||||||
|
state.currentPackage = packageName
|
||||||
|
TYPE_KEY_VALUE
|
||||||
|
}
|
||||||
|
BackupType.FULL -> {
|
||||||
|
val name = crypto.getNameForPackage(state.backup.salt, packageName)
|
||||||
|
full.initializeStateV1(state.token, name, packageInfo)
|
||||||
|
state.currentPackage = packageName
|
||||||
|
TYPE_FULL_STREAM
|
||||||
|
}
|
||||||
|
null -> {
|
||||||
|
Log.i(TAG, "No backup type found for $packageName. Skipping...")
|
||||||
|
state.backup.packageMetadataMap[packageName]?.backupType?.let { s ->
|
||||||
Log.w(TAG, "State was ${s.name}")
|
Log.w(TAG, "State was ${s.name}")
|
||||||
}
|
}
|
||||||
failedPackages.add(packageName)
|
failedPackages.add(packageName)
|
||||||
|
@ -280,18 +383,16 @@ internal class RestoreCoordinator(
|
||||||
// check key/value data first and if available, don't even check for full data
|
// check key/value data first and if available, don't even check for full data
|
||||||
kv.hasDataForPackage(state.token, packageInfo) -> {
|
kv.hasDataForPackage(state.token, packageInfo) -> {
|
||||||
Log.i(TAG, "Found K/V data for $packageName.")
|
Log.i(TAG, "Found K/V data for $packageName.")
|
||||||
kv.initializeState(0x00, state.token, "", packageInfo, null)
|
kv.initializeStateV0(state.token, packageInfo)
|
||||||
state.currentPackage = packageName
|
state.currentPackage = packageName
|
||||||
TYPE_KEY_VALUE
|
TYPE_KEY_VALUE
|
||||||
}
|
}
|
||||||
|
|
||||||
full.hasDataForPackage(state.token, packageInfo) -> {
|
full.hasDataForPackage(state.token, packageInfo) -> {
|
||||||
Log.i(TAG, "Found full backup data for $packageName.")
|
Log.i(TAG, "Found full backup data for $packageName.")
|
||||||
full.initializeState(0x00, state.token, "", packageInfo)
|
full.initializeStateV0(state.token, packageInfo)
|
||||||
state.currentPackage = packageName
|
state.currentPackage = packageName
|
||||||
TYPE_FULL_STREAM
|
TYPE_FULL_STREAM
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.i(TAG, "No data found for $packageName. Skipping.")
|
Log.i(TAG, "No data found for $packageName. Skipping.")
|
||||||
return nextRestorePackage()
|
return nextRestorePackage()
|
||||||
|
@ -315,6 +416,7 @@ internal class RestoreCoordinator(
|
||||||
* @return the same error codes as [startRestore].
|
* @return the same error codes as [startRestore].
|
||||||
*/
|
*/
|
||||||
suspend fun getRestoreData(data: ParcelFileDescriptor): Int {
|
suspend fun getRestoreData(data: ParcelFileDescriptor): Int {
|
||||||
|
Log.d(TAG, "getRestoreData()")
|
||||||
return kv.getRestoreData(data).apply {
|
return kv.getRestoreData(data).apply {
|
||||||
if (this != TRANSPORT_OK) {
|
if (this != TRANSPORT_OK) {
|
||||||
// add current package to failed ones
|
// add current package to failed ones
|
||||||
|
@ -352,7 +454,7 @@ internal class RestoreCoordinator(
|
||||||
*/
|
*/
|
||||||
fun finishRestore() {
|
fun finishRestore() {
|
||||||
Log.d(TAG, "finishRestore")
|
Log.d(TAG, "finishRestore")
|
||||||
if (full.hasState()) full.finishRestore()
|
if (full.hasState) full.finishRestore()
|
||||||
state = null
|
state = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,9 +10,20 @@ import org.koin.dsl.module
|
||||||
|
|
||||||
val restoreModule = module {
|
val restoreModule = module {
|
||||||
single { OutputFactory() }
|
single { OutputFactory() }
|
||||||
single { KVRestore(get(), get(), get(), get(), get(), get()) }
|
single { KVRestore(get(), get(), get(), get(), get(), get(), get()) }
|
||||||
single { FullRestore(get(), get(), get(), get(), get()) }
|
single { FullRestore(get(), get(), get(), get(), get(), get()) }
|
||||||
single {
|
single {
|
||||||
RestoreCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get())
|
RestoreCoordinator(
|
||||||
|
context = androidContext(),
|
||||||
|
crypto = get(),
|
||||||
|
settingsManager = get(),
|
||||||
|
metadataManager = get(),
|
||||||
|
notificationManager = get(),
|
||||||
|
backendManager = get(),
|
||||||
|
snapshotManager = get(),
|
||||||
|
kv = get(),
|
||||||
|
full = get(),
|
||||||
|
metadataReader = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ enum class AppBackupState {
|
||||||
FAILED -> notShownString
|
FAILED -> notShownString
|
||||||
FAILED_NO_DATA -> context.getString(R.string.backup_app_no_data)
|
FAILED_NO_DATA -> context.getString(R.string.backup_app_no_data)
|
||||||
FAILED_WAS_STOPPED -> context.getString(R.string.backup_app_was_stopped)
|
FAILED_WAS_STOPPED -> context.getString(R.string.backup_app_was_stopped)
|
||||||
FAILED_NOT_ALLOWED -> notShownString
|
FAILED_NOT_ALLOWED -> context.getString(R.string.restore_app_not_allowed)
|
||||||
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)
|
FAILED_NOT_INSTALLED -> context.getString(R.string.restore_app_not_installed)
|
||||||
FAILED_QUOTA_EXCEEDED -> context.getString(R.string.backup_app_quota_exceeded)
|
FAILED_QUOTA_EXCEEDED -> context.getString(R.string.backup_app_quota_exceeded)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import androidx.annotation.CallSuper
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.extensions.setupEdgeToEdge
|
|
||||||
|
|
||||||
abstract class BackupActivity : AppCompatActivity() {
|
abstract class BackupActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,9 @@ package com.stevesoltys.seedvault.ui
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
|
||||||
abstract class RequireProvisioningViewModel(
|
abstract class RequireProvisioningViewModel(
|
||||||
|
@ -26,7 +27,7 @@ abstract class RequireProvisioningViewModel(
|
||||||
|
|
||||||
internal fun validLocationIsSet() = backendManager.isValidAppPluginSet()
|
internal fun validLocationIsSet() = backendManager.isValidAppPluginSet()
|
||||||
|
|
||||||
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey()
|
internal fun recoveryCodeIsSet() = permitDiskReads { keyManager.hasBackupKey() }
|
||||||
|
|
||||||
open fun onStorageLocationChanged() {
|
open fun onStorageLocationChanged() {
|
||||||
// noop can be overwritten by sub-classes
|
// noop can be overwritten by sub-classes
|
||||||
|
|
|
@ -8,10 +8,27 @@ package com.stevesoltys.seedvault.ui
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.format.DateUtils.MINUTE_IN_MILLIS
|
import android.text.format.DateUtils.MINUTE_IN_MILLIS
|
||||||
import android.text.format.DateUtils.getRelativeTimeSpanString
|
import android.text.format.DateUtils.getRelativeTimeSpanString
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener
|
||||||
|
import androidx.core.view.WindowCompat.setDecorFitsSystemWindows
|
||||||
|
import androidx.core.view.WindowInsetsCompat.CONSUMED
|
||||||
|
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
|
||||||
|
import androidx.core.view.WindowInsetsCompat.Type.ime
|
||||||
|
import androidx.core.view.WindowInsetsCompat.Type.systemBars
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
|
|
||||||
|
fun AppCompatActivity.setupEdgeToEdge() {
|
||||||
|
val rootView = window.decorView.rootView
|
||||||
|
setDecorFitsSystemWindows(window, false)
|
||||||
|
setOnApplyWindowInsetsListener(rootView) { v, windowInsets ->
|
||||||
|
val insets = windowInsets.getInsets(systemBars() or ime() or displayCutout())
|
||||||
|
v.setPadding(insets.left, insets.top, insets.right, insets.bottom)
|
||||||
|
CONSUMED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun Long.toRelativeTime(context: Context): CharSequence {
|
fun Long.toRelativeTime(context: Context): CharSequence {
|
||||||
return if (this == 0L) {
|
return if (this == 0L || this == -1L) {
|
||||||
context.getString(R.string.settings_backup_last_backup_never)
|
context.getString(R.string.settings_backup_last_backup_never)
|
||||||
} else {
|
} else {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|
|
@ -31,6 +31,7 @@ import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL
|
import com.stevesoltys.seedvault.restore.ACTION_RESTORE_ERROR_UNINSTALL
|
||||||
import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME
|
import com.stevesoltys.seedvault.restore.EXTRA_PACKAGE_NAME
|
||||||
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
|
import com.stevesoltys.seedvault.restore.REQUEST_CODE_UNINSTALL
|
||||||
|
import com.stevesoltys.seedvault.restore.RestoreActivity
|
||||||
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
|
import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
|
||||||
import com.stevesoltys.seedvault.settings.SettingsActivity
|
import com.stevesoltys.seedvault.settings.SettingsActivity
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
@ -40,13 +41,14 @@ private const val CHANNEL_ID_SUCCESS = "NotificationBackupSuccess"
|
||||||
private const val CHANNEL_ID_ERROR = "NotificationError"
|
private const val CHANNEL_ID_ERROR = "NotificationError"
|
||||||
private const val CHANNEL_ID_RESTORE = "NotificationRestore"
|
private const val CHANNEL_ID_RESTORE = "NotificationRestore"
|
||||||
private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError"
|
private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError"
|
||||||
|
private const val CHANNEL_ID_PRUNING = "NotificationPruning"
|
||||||
internal const val NOTIFICATION_ID_OBSERVER = 1
|
internal const val NOTIFICATION_ID_OBSERVER = 1
|
||||||
private const val NOTIFICATION_ID_SUCCESS = 2
|
private const val NOTIFICATION_ID_SUCCESS = 2
|
||||||
private const val NOTIFICATION_ID_ERROR = 3
|
private const val NOTIFICATION_ID_ERROR = 3
|
||||||
private const val NOTIFICATION_ID_SPACE_ERROR = 4
|
private const val NOTIFICATION_ID_SPACE_ERROR = 4
|
||||||
internal const val NOTIFICATION_ID_RESTORE = 5
|
internal const val NOTIFICATION_ID_RESTORE = 5
|
||||||
private const val NOTIFICATION_ID_RESTORE_ERROR = 6
|
private const val NOTIFICATION_ID_RESTORE_ERROR = 6
|
||||||
private const val NOTIFICATION_ID_BACKGROUND = 7
|
internal const val NOTIFICATION_ID_PRUNING = 7
|
||||||
private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 8
|
private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 8
|
||||||
|
|
||||||
private val TAG = BackupNotificationManager::class.java.simpleName
|
private val TAG = BackupNotificationManager::class.java.simpleName
|
||||||
|
@ -59,6 +61,7 @@ internal class BackupNotificationManager(private val context: Context) {
|
||||||
createNotificationChannel(getErrorChannel())
|
createNotificationChannel(getErrorChannel())
|
||||||
createNotificationChannel(getRestoreChannel())
|
createNotificationChannel(getRestoreChannel())
|
||||||
createNotificationChannel(getRestoreErrorChannel())
|
createNotificationChannel(getRestoreErrorChannel())
|
||||||
|
createNotificationChannel(getPruningChannel())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getObserverChannel(): NotificationChannel {
|
private fun getObserverChannel(): NotificationChannel {
|
||||||
|
@ -90,6 +93,11 @@ internal class BackupNotificationManager(private val context: Context) {
|
||||||
return NotificationChannel(CHANNEL_ID_RESTORE_ERROR, title, IMPORTANCE_HIGH)
|
return NotificationChannel(CHANNEL_ID_RESTORE_ERROR, title, IMPORTANCE_HIGH)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getPruningChannel(): NotificationChannel {
|
||||||
|
val title = context.getString(R.string.notification_pruning_channel_title)
|
||||||
|
return NotificationChannel(CHANNEL_ID_PRUNING, title, IMPORTANCE_LOW)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This should get called for each APK we are backing up.
|
* This should get called for each APK we are backing up.
|
||||||
*/
|
*/
|
||||||
|
@ -158,7 +166,6 @@ internal class BackupNotificationManager(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onServiceDestroyed() {
|
fun onServiceDestroyed() {
|
||||||
nm.cancel(NOTIFICATION_ID_BACKGROUND)
|
|
||||||
// Cancel left-over notifications that are still ongoing.
|
// Cancel left-over notifications that are still ongoing.
|
||||||
//
|
//
|
||||||
// We have seen a race condition where the service was taken down at the same time
|
// We have seen a race condition where the service was taken down at the same time
|
||||||
|
@ -179,21 +186,17 @@ internal class BackupNotificationManager(private val context: Context) {
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onBackupFinished(success: Boolean, numBackedUp: Int?, total: Int, size: Long) {
|
fun onBackupSuccess(numBackedUp: Int, total: Int, size: Long) {
|
||||||
val titleRes =
|
val sizeStr = Formatter.formatShortFileSize(context, size)
|
||||||
if (success) R.string.notification_success_title else R.string.notification_failed_title
|
val contentText =
|
||||||
val contentText = if (numBackedUp == null) null else {
|
|
||||||
val sizeStr = Formatter.formatShortFileSize(context, size)
|
|
||||||
context.getString(R.string.notification_success_text, numBackedUp, total, sizeStr)
|
context.getString(R.string.notification_success_text, numBackedUp, total, sizeStr)
|
||||||
}
|
|
||||||
val iconRes = if (success) R.drawable.ic_cloud_done else R.drawable.ic_cloud_error
|
|
||||||
val intent = Intent(context, SettingsActivity::class.java).apply {
|
val intent = Intent(context, SettingsActivity::class.java).apply {
|
||||||
if (success) action = ACTION_APP_STATUS_LIST
|
action = ACTION_APP_STATUS_LIST
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||||
val notification = Builder(context, CHANNEL_ID_SUCCESS).apply {
|
val notification = Builder(context, CHANNEL_ID_SUCCESS).apply {
|
||||||
setSmallIcon(iconRes)
|
setSmallIcon(R.drawable.ic_cloud_done)
|
||||||
setContentTitle(context.getString(titleRes))
|
setContentTitle(context.getString(R.string.notification_success_title))
|
||||||
setContentText(contentText)
|
setContentText(contentText)
|
||||||
setOngoing(false)
|
setOngoing(false)
|
||||||
setShowWhen(true)
|
setShowWhen(true)
|
||||||
|
@ -207,8 +210,27 @@ internal class BackupNotificationManager(private val context: Context) {
|
||||||
nm.notify(NOTIFICATION_ID_SUCCESS, notification)
|
nm.notify(NOTIFICATION_ID_SUCCESS, notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
|
||||||
fun onBackupError() {
|
fun onBackupError() {
|
||||||
|
val intent = Intent(context, SettingsActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||||
|
val notification = Builder(context, CHANNEL_ID_ERROR).apply {
|
||||||
|
setSmallIcon(R.drawable.ic_cloud_error)
|
||||||
|
setContentTitle(context.getString(R.string.notification_failed_title))
|
||||||
|
setContentText(context.getString(R.string.notification_failed_text))
|
||||||
|
setOngoing(false)
|
||||||
|
setShowWhen(true)
|
||||||
|
setAutoCancel(true)
|
||||||
|
setContentIntent(pendingIntent)
|
||||||
|
setWhen(System.currentTimeMillis())
|
||||||
|
setProgress(0, 0, false)
|
||||||
|
priority = PRIORITY_LOW
|
||||||
|
}.build()
|
||||||
|
nm.cancel(NOTIFICATION_ID_OBSERVER)
|
||||||
|
nm.notify(NOTIFICATION_ID_ERROR, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("RestrictedApi")
|
||||||
|
fun onFixableBackupError() {
|
||||||
val intent = Intent(context, SettingsActivity::class.java)
|
val intent = Intent(context, SettingsActivity::class.java)
|
||||||
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||||
val actionText = context.getString(R.string.notification_error_action)
|
val actionText = context.getString(R.string.notification_error_action)
|
||||||
|
@ -244,6 +266,9 @@ internal class BackupNotificationManager(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getRestoreNotification() = Notification.Builder(context, CHANNEL_ID_RESTORE).apply {
|
fun getRestoreNotification() = Notification.Builder(context, CHANNEL_ID_RESTORE).apply {
|
||||||
|
val intent = Intent(context, RestoreActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
|
||||||
|
setContentIntent(pendingIntent)
|
||||||
setSmallIcon(R.drawable.ic_cloud_restore)
|
setSmallIcon(R.drawable.ic_cloud_restore)
|
||||||
setContentTitle(context.getString(R.string.notification_restore_title))
|
setContentTitle(context.getString(R.string.notification_restore_title))
|
||||||
setOngoing(true)
|
setOngoing(true)
|
||||||
|
@ -288,6 +313,17 @@ internal class BackupNotificationManager(private val context: Context) {
|
||||||
nm.cancel(NOTIFICATION_ID_RESTORE_ERROR)
|
nm.cancel(NOTIFICATION_ID_RESTORE_ERROR)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getPruningNotification(): Notification {
|
||||||
|
return Builder(context, CHANNEL_ID_OBSERVER).apply {
|
||||||
|
setSmallIcon(R.drawable.ic_auto_delete)
|
||||||
|
setContentTitle(context.getString(R.string.notification_pruning_title))
|
||||||
|
setOngoing(true)
|
||||||
|
setShowWhen(false)
|
||||||
|
priority = PRIORITY_LOW
|
||||||
|
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
fun onNoMainKeyError() {
|
fun onNoMainKeyError() {
|
||||||
val intent = Intent(context, SettingsActivity::class.java)
|
val intent = Intent(context, SettingsActivity::class.java)
|
||||||
|
|
|
@ -11,16 +11,23 @@ import android.app.backup.IBackupObserver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
import android.content.pm.ApplicationInfo.FLAG_SYSTEM
|
||||||
import android.content.pm.PackageManager.NameNotFoundException
|
import android.content.pm.PackageManager.NameNotFoundException
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Log.INFO
|
import android.util.Log.INFO
|
||||||
import android.util.Log.isLoggable
|
import android.util.Log.isLoggable
|
||||||
import com.stevesoltys.seedvault.ERROR_BACKUP_CANCELLED
|
import com.stevesoltys.seedvault.ERROR_BACKUP_CANCELLED
|
||||||
|
import com.stevesoltys.seedvault.ERROR_BACKUP_NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
|
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||||
|
import com.stevesoltys.seedvault.repo.hexFromProto
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
|
import com.stevesoltys.seedvault.worker.AppBackupPruneWorker
|
||||||
import com.stevesoltys.seedvault.worker.BackupRequester
|
import com.stevesoltys.seedvault.worker.BackupRequester
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
@ -33,15 +40,19 @@ internal class NotificationBackupObserver(
|
||||||
) : IBackupObserver.Stub(), KoinComponent {
|
) : IBackupObserver.Stub(), KoinComponent {
|
||||||
|
|
||||||
private val nm: BackupNotificationManager by inject()
|
private val nm: BackupNotificationManager by inject()
|
||||||
private val metadataManager: MetadataManager by inject()
|
|
||||||
private val packageService: PackageService by inject()
|
private val packageService: PackageService by inject()
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
|
private val metadataManager: MetadataManager by inject()
|
||||||
|
private val appBackupManager: AppBackupManager by inject()
|
||||||
private var currentPackage: String? = null
|
private var currentPackage: String? = null
|
||||||
private var numPackages: Int = 0
|
private var numPackages: Int = 0
|
||||||
private var numPackagesToReport: Int = 0
|
private var numPackagesToReport: Int = 0
|
||||||
private var pmCounted: Boolean = false
|
private var pmCounted: Boolean = false
|
||||||
|
|
||||||
private var errorPackageName: String? = null
|
private var errorPackageName: String? = null
|
||||||
|
private val launchableSystemApps by lazy {
|
||||||
|
packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Inform the notification manager that a backup has started
|
// Inform the notification manager that a backup has started
|
||||||
|
@ -73,7 +84,7 @@ internal class NotificationBackupObserver(
|
||||||
* that was initialized
|
* that was initialized
|
||||||
* @param status Zero on success; a nonzero error code if the backup operation failed.
|
* @param status Zero on success; a nonzero error code if the backup operation failed.
|
||||||
*/
|
*/
|
||||||
override fun onResult(target: String?, status: Int) {
|
override fun onResult(target: String, status: Int) {
|
||||||
if (isLoggable(TAG, INFO)) {
|
if (isLoggable(TAG, INFO)) {
|
||||||
Log.i(TAG, "Completed. Target: $target, status: $status")
|
Log.i(TAG, "Completed. Target: $target, status: $status")
|
||||||
}
|
}
|
||||||
|
@ -87,7 +98,7 @@ internal class NotificationBackupObserver(
|
||||||
numPackages += 1
|
numPackages += 1
|
||||||
}
|
}
|
||||||
// count package if success and not a system app
|
// count package if success and not a system app
|
||||||
if (status == 0 && target != null && target != MAGIC_PACKAGE_MANAGER) try {
|
if (status == 0 && target != MAGIC_PACKAGE_MANAGER) try {
|
||||||
val appInfo = context.packageManager.getApplicationInfo(target, 0)
|
val appInfo = context.packageManager.getApplicationInfo(target, 0)
|
||||||
// exclude system apps from final count for now
|
// exclude system apps from final count for now
|
||||||
if (appInfo.flags and FLAG_SYSTEM == 0) {
|
if (appInfo.flags and FLAG_SYSTEM == 0) {
|
||||||
|
@ -97,6 +108,14 @@ internal class NotificationBackupObserver(
|
||||||
// should only happen for MAGIC_PACKAGE_MANAGER, but better save than sorry
|
// should only happen for MAGIC_PACKAGE_MANAGER, but better save than sorry
|
||||||
Log.e(TAG, "Error getting ApplicationInfo: ", e)
|
Log.e(TAG, "Error getting ApplicationInfo: ", e)
|
||||||
}
|
}
|
||||||
|
// record status for not-allowed apps visible in UI
|
||||||
|
if (status == ERROR_BACKUP_NOT_ALLOWED) {
|
||||||
|
// this should only ever happen for system apps, as we use only d2d now
|
||||||
|
if (target in launchableSystemApps) {
|
||||||
|
val packageInfo = context.packageManager.getPackageInfo(target, 0)
|
||||||
|
metadataManager.onPackageBackupError(packageInfo, NOT_ALLOWED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apps that get killed while interacting with their [BackupAgent] cancel the entire backup.
|
// Apps that get killed while interacting with their [BackupAgent] cancel the entire backup.
|
||||||
// In order to prevent them from DoSing us, we remember them here to auto-disable them.
|
// In order to prevent them from DoSing us, we remember them here to auto-disable them.
|
||||||
|
@ -133,15 +152,33 @@ internal class NotificationBackupObserver(
|
||||||
if (isLoggable(TAG, INFO)) {
|
if (isLoggable(TAG, INFO)) {
|
||||||
Log.i(TAG, "Backup finished $numPackages/$requestedPackages. Status: $status")
|
Log.i(TAG, "Backup finished $numPackages/$requestedPackages. Status: $status")
|
||||||
}
|
}
|
||||||
val success = status == 0
|
var success = status == 0
|
||||||
val size = if (success) metadataManager.getPackagesBackupSize() else 0L
|
|
||||||
val total = try {
|
val total = try {
|
||||||
packageService.allUserPackages.size
|
packageService.allUserPackages.size
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error getting number of all user packages: ", e)
|
Log.e(TAG, "Error getting number of all user packages: ", e)
|
||||||
requestedPackages
|
requestedPackages
|
||||||
}
|
}
|
||||||
nm.onBackupFinished(success, numPackagesToReport, total, size)
|
val snapshot = runBlocking {
|
||||||
|
check(!Looper.getMainLooper().isCurrentThread)
|
||||||
|
Log.d(TAG, "Finalizing backup...")
|
||||||
|
val snapshot = appBackupManager.afterBackupFinished(success)
|
||||||
|
success = snapshot != null
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
val size = if (snapshot != null) { // TODO for later: count size of APKs separately
|
||||||
|
val chunkIds = snapshot.appsMap.values.flatMap { it.chunkIdsList }
|
||||||
|
chunkIds.sumOf {
|
||||||
|
snapshot.blobsMap[it.hexFromProto()]?.uncompressedLength?.toLong() ?: 0L
|
||||||
|
}
|
||||||
|
} else 0L
|
||||||
|
if (success) {
|
||||||
|
nm.onBackupSuccess(numPackagesToReport, total, size)
|
||||||
|
// prune old backups
|
||||||
|
AppBackupPruneWorker.scheduleNow(context)
|
||||||
|
} else {
|
||||||
|
nm.onBackupError()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,15 +26,15 @@ class RecoveryCodeActivity : BackupActivity() {
|
||||||
setContentView(R.layout.activity_recovery_code)
|
setContentView(R.layout.activity_recovery_code)
|
||||||
|
|
||||||
viewModel.isRestore = isRestore()
|
viewModel.isRestore = isRestore()
|
||||||
viewModel.confirmButtonClicked.observeEvent(this, { clicked ->
|
viewModel.confirmButtonClicked.observeEvent(this) { clicked ->
|
||||||
if (clicked) showInput(true)
|
if (clicked) showInput(true)
|
||||||
})
|
}
|
||||||
viewModel.recoveryCodeSaved.observeEvent(this, { saved ->
|
viewModel.recoveryCodeSaved.observeEvent(this) { saved ->
|
||||||
if (saved) {
|
if (saved) {
|
||||||
setResult(RESULT_OK)
|
setResult(RESULT_OK)
|
||||||
finishAfterTransition()
|
finishAfterTransition()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
if (viewModel.isRestore) showInput(false)
|
if (viewModel.isRestore) showInput(false)
|
||||||
|
|
|
@ -16,15 +16,18 @@ import cash.z.ecc.android.bip39.toSeed
|
||||||
import com.stevesoltys.seedvault.App
|
import com.stevesoltys.seedvault.App
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.repo.AppBackupManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.calyxos.backup.storage.api.StorageBackup
|
import org.calyxos.backup.storage.api.StorageBackup
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
internal const val WORD_NUM = 12
|
internal const val WORD_NUM = 12
|
||||||
|
|
||||||
|
@ -35,6 +38,7 @@ internal class RecoveryCodeViewModel(
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
private val keyManager: KeyManager,
|
private val keyManager: KeyManager,
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
|
private val appBackupManager: AppBackupManager,
|
||||||
private val backupInitializer: BackupInitializer,
|
private val backupInitializer: BackupInitializer,
|
||||||
private val notificationManager: BackupNotificationManager,
|
private val notificationManager: BackupNotificationManager,
|
||||||
private val storageBackup: StorageBackup,
|
private val storageBackup: StorageBackup,
|
||||||
|
@ -104,20 +108,33 @@ internal class RecoveryCodeViewModel(
|
||||||
* The reason is that old backups won't be readable anymore with the new key.
|
* The reason is that old backups won't be readable anymore with the new key.
|
||||||
* We can't delete other backups safely, because we can't be sure
|
* We can't delete other backups safely, because we can't be sure
|
||||||
* that they don't belong to a different device or user.
|
* that they don't belong to a different device or user.
|
||||||
|
*
|
||||||
|
* Our process will be terminated at the end to ensure the old key isn't used anymore.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
fun reinitializeBackupLocation() {
|
fun reinitializeBackupLocation() {
|
||||||
Log.d(TAG, "Re-initializing backup location...")
|
Log.d(TAG, "Re-initializing backup location...")
|
||||||
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
// remove old backup repository and clear local blob cache
|
||||||
|
try {
|
||||||
|
appBackupManager.removeBackupRepo()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Error removing backup repo: ", e)
|
||||||
|
}
|
||||||
// remove old storage snapshots and clear cache
|
// remove old storage snapshots and clear cache
|
||||||
storageBackup.init()
|
storageBackup.init()
|
||||||
|
// we'll need to kill our process to not have references to the old key around
|
||||||
|
// trying to re-set all those references is complicated, so exiting the app is easier.
|
||||||
|
val exitApp = {
|
||||||
|
Log.w(TAG, "Shutting down app...")
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// initialize the new location
|
// initialize the new location
|
||||||
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
|
if (backupManager.isBackupEnabled) backupInitializer.initialize(exitApp, exitApp)
|
||||||
// no-op
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error starting new RestoreSet", e)
|
Log.e(TAG, "Error starting new RestoreSet", e)
|
||||||
|
exitApp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,6 @@ import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.backend.saf.SafHandler
|
import com.stevesoltys.seedvault.backend.saf.SafHandler
|
||||||
import com.stevesoltys.seedvault.backend.webdav.WebDavHandler
|
import com.stevesoltys.seedvault.backend.webdav.WebDavHandler
|
||||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
import com.stevesoltys.seedvault.storage.StorageBackupJobService
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||||
|
@ -28,6 +27,7 @@ import org.calyxos.backup.storage.api.StorageBackup
|
||||||
import org.calyxos.backup.storage.backup.BackupJobService
|
import org.calyxos.backup.storage.backup.BackupJobService
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ package com.stevesoltys.seedvault.ui.storage
|
||||||
import android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
import android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import com.stevesoltys.seedvault.extensions.setupEdgeToEdge
|
import com.stevesoltys.seedvault.ui.setupEdgeToEdge
|
||||||
|
|
||||||
class PermissionGrantActivity : AppCompatActivity() {
|
class PermissionGrantActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,6 @@ import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT
|
import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
private val TAG = RestoreStorageViewModel::class.java.simpleName
|
private val TAG = RestoreStorageViewModel::class.java.simpleName
|
||||||
|
|
||||||
|
@ -37,9 +36,11 @@ internal class RestoreStorageViewModel(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val hasBackup = try {
|
val hasBackup = try {
|
||||||
safHandler.hasAppBackup(safProperties)
|
safHandler.hasAppBackup(safProperties)
|
||||||
} catch (e: IOException) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error reading URI: ${safProperties.uri}", e)
|
Log.e(TAG, "Error reading URI: ${safProperties.uri}", e)
|
||||||
false
|
val errorMsg = app.getString(R.string.restore_set_error) + "\n\n$e"
|
||||||
|
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
if (hasBackup) {
|
if (hasBackup) {
|
||||||
safHandler.save(safProperties)
|
safHandler.save(safProperties)
|
||||||
|
@ -60,9 +61,11 @@ internal class RestoreStorageViewModel(
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val hasBackup = try {
|
val hasBackup = try {
|
||||||
webdavHandler.hasAppBackup(backend)
|
webdavHandler.hasAppBackup(backend)
|
||||||
} catch (e: IOException) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error reading: ${properties.config.url}", e)
|
Log.e(TAG, "Error reading: ${properties.config.url}", e)
|
||||||
false
|
val errorMsg = app.getString(R.string.restore_set_error) + "\n\n$e"
|
||||||
|
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
if (hasBackup) {
|
if (hasBackup) {
|
||||||
webdavHandler.save(properties)
|
webdavHandler.save(properties)
|
||||||
|
|
|
@ -24,8 +24,6 @@ import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
|
||||||
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_SETUP_WIZARD
|
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_SETUP_WIZARD
|
||||||
import org.koin.androidx.viewmodel.ext.android.getViewModel
|
import org.koin.androidx.viewmodel.ext.android.getViewModel
|
||||||
|
|
||||||
private val TAG = StorageActivity::class.java.name
|
|
||||||
|
|
||||||
class StorageActivity : BackupActivity() {
|
class StorageActivity : BackupActivity() {
|
||||||
|
|
||||||
private lateinit var viewModel: StorageViewModel
|
private lateinit var viewModel: StorageViewModel
|
||||||
|
|
|
@ -20,7 +20,6 @@ import android.view.View
|
||||||
import android.view.View.INVISIBLE
|
import android.view.View.INVISIBLE
|
||||||
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.ProgressBar
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
|
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
|
||||||
|
@ -47,8 +46,6 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
||||||
|
|
||||||
private lateinit var viewModel: StorageViewModel
|
private lateinit var viewModel: StorageViewModel
|
||||||
private lateinit var titleView: TextView
|
private lateinit var titleView: TextView
|
||||||
private lateinit var warningIcon: ImageView
|
|
||||||
private lateinit var warningText: TextView
|
|
||||||
private lateinit var listView: RecyclerView
|
private lateinit var listView: RecyclerView
|
||||||
private lateinit var progressBar: ProgressBar
|
private lateinit var progressBar: ProgressBar
|
||||||
private lateinit var skipView: TextView
|
private lateinit var skipView: TextView
|
||||||
|
@ -63,8 +60,6 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
||||||
val v: View = inflater.inflate(R.layout.fragment_storage_options, container, false)
|
val v: View = inflater.inflate(R.layout.fragment_storage_options, container, false)
|
||||||
|
|
||||||
titleView = v.requireViewById(R.id.titleView)
|
titleView = v.requireViewById(R.id.titleView)
|
||||||
warningIcon = v.requireViewById(R.id.warningIcon)
|
|
||||||
warningText = v.requireViewById(R.id.warningText)
|
|
||||||
listView = v.requireViewById(R.id.listView)
|
listView = v.requireViewById(R.id.listView)
|
||||||
progressBar = v.requireViewById(R.id.progressBar)
|
progressBar = v.requireViewById(R.id.progressBar)
|
||||||
skipView = v.requireViewById(R.id.skipView)
|
skipView = v.requireViewById(R.id.skipView)
|
||||||
|
@ -90,12 +85,6 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
||||||
requireActivity().setResult(RESULT_FIRST_USER)
|
requireActivity().setResult(RESULT_FIRST_USER)
|
||||||
requireActivity().finishAfterTransition()
|
requireActivity().finishAfterTransition()
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
warningIcon.visibility = VISIBLE
|
|
||||||
if (viewModel.hasStorageSet) {
|
|
||||||
warningText.setText(R.string.storage_fragment_warning_delete)
|
|
||||||
}
|
|
||||||
warningText.visibility = VISIBLE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
listView.adapter = adapter
|
listView.adapter = adapter
|
||||||
|
|
|
@ -16,7 +16,6 @@ import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.backend.saf.SafHandler
|
import com.stevesoltys.seedvault.backend.saf.SafHandler
|
||||||
import com.stevesoltys.seedvault.backend.webdav.WebDavHandler
|
import com.stevesoltys.seedvault.backend.webdav.WebDavHandler
|
||||||
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||||
|
@ -26,6 +25,7 @@ import kotlinx.coroutines.launch
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
||||||
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
import org.calyxos.seedvault.core.backends.webdav.WebDavConfig
|
||||||
|
import org.calyxos.seedvault.core.backends.webdav.WebDavProperties
|
||||||
|
|
||||||
internal abstract class StorageViewModel(
|
internal abstract class StorageViewModel(
|
||||||
private val app: Application,
|
private val app: Application,
|
||||||
|
@ -48,8 +48,6 @@ internal abstract class StorageViewModel(
|
||||||
private var safOption: SafOption? = null
|
private var safOption: SafOption? = null
|
||||||
|
|
||||||
internal var isSetupWizard: Boolean = false
|
internal var isSetupWizard: Boolean = false
|
||||||
internal val hasStorageSet: Boolean
|
|
||||||
get() = backendManager.backendProperties != null
|
|
||||||
abstract val isRestoreOperation: Boolean
|
abstract val isRestoreOperation: Boolean
|
||||||
|
|
||||||
internal fun loadStorageRoots() {
|
internal fun loadStorageRoots() {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue