Merge pull request #750 from grote/app-backup-v2

App backup format v2 with compression and deduplication
This commit is contained in:
Torsten Grote 2024-10-10 17:29:58 -03:00 committed by GitHub
commit 5365ef3a5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
213 changed files with 10054 additions and 4090 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; apps = 9;</code>
*/
public val apps: com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
@kotlin.jvm.JvmSynthetic
@JvmName("getAppsMap")
get() = com.google.protobuf.kotlin.DslMap(
_builder.getAppsMap()
)
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; apps = 9;</code>
*/
@JvmName("putApps")
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
.put(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.App) {
_builder.putApps(key, value)
}
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; apps = 9;</code>
*/
@kotlin.jvm.JvmSynthetic
@JvmName("setApps")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
.set(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.App) {
put(key, value)
}
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; apps = 9;</code>
*/
@kotlin.jvm.JvmSynthetic
@JvmName("removeApps")
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
.remove(key: kotlin.String) {
_builder.removeApps(key)
}
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; apps = 9;</code>
*/
@kotlin.jvm.JvmSynthetic
@JvmName("putAllApps")
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
.putAll(map: kotlin.collections.Map<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App>) {
_builder.putAllApps(map)
}
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.App&gt; apps = 9;</code>
*/
@kotlin.jvm.JvmSynthetic
@JvmName("clearApps")
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.App, AppsProxy>
.clear() {
_builder.clearApps()
}
/**
* An uninstantiable, behaviorless type to represent the field in
* generics.
*/
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
public class IconChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
/**
* <code>repeated bytes iconChunkIds = 10;</code>
*/
public val iconChunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>
@kotlin.jvm.JvmSynthetic
get() = com.google.protobuf.kotlin.DslList(
_builder.getIconChunkIdsList()
)
/**
* <code>repeated bytes iconChunkIds = 10;</code>
* @param value The iconChunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addIconChunkIds")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
_builder.addIconChunkIds(value)
}/**
* <code>repeated bytes iconChunkIds = 10;</code>
* @param value The iconChunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignIconChunkIds")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
add(value)
}/**
* <code>repeated bytes iconChunkIds = 10;</code>
* @param values The iconChunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addAllIconChunkIds")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
_builder.addAllIconChunkIds(values)
}/**
* <code>repeated bytes iconChunkIds = 10;</code>
* @param values The iconChunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignAllIconChunkIds")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
addAll(values)
}/**
* <code>repeated bytes iconChunkIds = 10;</code>
* @param index The index to set the value at.
* @param value The iconChunkIds to set.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("setIconChunkIds")
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
_builder.setIconChunkIds(index, value)
}/**
* <code>repeated bytes iconChunkIds = 10;</code>
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("clearIconChunkIds")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, IconChunkIdsProxy>.clear() {
_builder.clearIconChunkIds()
}
/**
* An uninstantiable, behaviorless type to represent the field in
* generics.
*/
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
public class BlobsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; blobs = 11;</code>
*/
public val blobs: com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
@kotlin.jvm.JvmSynthetic
@JvmName("getBlobsMap")
get() = com.google.protobuf.kotlin.DslMap(
_builder.getBlobsMap()
)
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; blobs = 11;</code>
*/
@JvmName("putBlobs")
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
.put(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.Blob) {
_builder.putBlobs(key, value)
}
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; blobs = 11;</code>
*/
@kotlin.jvm.JvmSynthetic
@JvmName("setBlobs")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
.set(key: kotlin.String, value: com.stevesoltys.seedvault.proto.Snapshot.Blob) {
put(key, value)
}
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; blobs = 11;</code>
*/
@kotlin.jvm.JvmSynthetic
@JvmName("removeBlobs")
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
.remove(key: kotlin.String) {
_builder.removeBlobs(key)
}
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; blobs = 11;</code>
*/
@kotlin.jvm.JvmSynthetic
@JvmName("putAllBlobs")
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
.putAll(map: kotlin.collections.Map<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob>) {
_builder.putAllBlobs(map)
}
/**
* <code>map&lt;string, .com.stevesoltys.seedvault.proto.Snapshot.Blob&gt; blobs = 11;</code>
*/
@kotlin.jvm.JvmSynthetic
@JvmName("clearBlobs")
public fun com.google.protobuf.kotlin.DslMap<kotlin.String, com.stevesoltys.seedvault.proto.Snapshot.Blob, BlobsProxy>
.clear() {
_builder.clearBlobs()
}
}
@kotlin.jvm.JvmName("-initializeapp")
public inline fun app(block: com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.App =
com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.App.newBuilder()).apply { block() }._build()
public object AppKt {
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
@com.google.protobuf.kotlin.ProtoDslMarker
public class Dsl private constructor(
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.App.Builder
) {
public companion object {
@kotlin.jvm.JvmSynthetic
@kotlin.PublishedApi
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.App.Builder): Dsl = Dsl(builder)
}
@kotlin.jvm.JvmSynthetic
@kotlin.PublishedApi
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.App = _builder.build()
/**
* <code>uint64 time = 1;</code>
*/
public var time: kotlin.Long
@JvmName("getTime")
get() = _builder.getTime()
@JvmName("setTime")
set(value) {
_builder.setTime(value)
}
/**
* <code>uint64 time = 1;</code>
*/
public fun clearTime() {
_builder.clearTime()
}
/**
* <code>.com.stevesoltys.seedvault.proto.Snapshot.BackupType type = 2;</code>
*/
public var type: com.stevesoltys.seedvault.proto.Snapshot.BackupType
@JvmName("getType")
get() = _builder.getType()
@JvmName("setType")
set(value) {
_builder.setType(value)
}
/**
* <code>.com.stevesoltys.seedvault.proto.Snapshot.BackupType type = 2;</code>
*/
public fun clearType() {
_builder.clearType()
}
/**
* <code>string name = 3;</code>
*/
public var name: kotlin.String
@JvmName("getName")
get() = _builder.getName()
@JvmName("setName")
set(value) {
_builder.setName(value)
}
/**
* <code>string name = 3;</code>
*/
public fun clearName() {
_builder.clearName()
}
/**
* <code>bool system = 4;</code>
*/
public var system: kotlin.Boolean
@JvmName("getSystem")
get() = _builder.getSystem()
@JvmName("setSystem")
set(value) {
_builder.setSystem(value)
}
/**
* <code>bool system = 4;</code>
*/
public fun clearSystem() {
_builder.clearSystem()
}
/**
* <code>bool launchableSystemApp = 5;</code>
*/
public var launchableSystemApp: kotlin.Boolean
@JvmName("getLaunchableSystemApp")
get() = _builder.getLaunchableSystemApp()
@JvmName("setLaunchableSystemApp")
set(value) {
_builder.setLaunchableSystemApp(value)
}
/**
* <code>bool launchableSystemApp = 5;</code>
*/
public fun clearLaunchableSystemApp() {
_builder.clearLaunchableSystemApp()
}
/**
* An uninstantiable, behaviorless type to represent the field in
* generics.
*/
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
public class ChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
/**
* <code>repeated bytes chunkIds = 6;</code>
*/
public val chunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>
@kotlin.jvm.JvmSynthetic
get() = com.google.protobuf.kotlin.DslList(
_builder.getChunkIdsList()
)
/**
* <code>repeated bytes chunkIds = 6;</code>
* @param value The chunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addChunkIds")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
_builder.addChunkIds(value)
}/**
* <code>repeated bytes chunkIds = 6;</code>
* @param value The chunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignChunkIds")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
add(value)
}/**
* <code>repeated bytes chunkIds = 6;</code>
* @param values The chunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addAllChunkIds")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
_builder.addAllChunkIds(values)
}/**
* <code>repeated bytes chunkIds = 6;</code>
* @param values The chunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignAllChunkIds")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
addAll(values)
}/**
* <code>repeated bytes chunkIds = 6;</code>
* @param index The index to set the value at.
* @param value The chunkIds to set.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("setChunkIds")
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
_builder.setChunkIds(index, value)
}/**
* <code>repeated bytes chunkIds = 6;</code>
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("clearChunkIds")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.clear() {
_builder.clearChunkIds()
}
/**
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
*/
public var apk: com.stevesoltys.seedvault.proto.Snapshot.Apk
@JvmName("getApk")
get() = _builder.getApk()
@JvmName("setApk")
set(value) {
_builder.setApk(value)
}
/**
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
*/
public fun clearApk() {
_builder.clearApk()
}
/**
* <code>.com.stevesoltys.seedvault.proto.Snapshot.Apk apk = 7;</code>
* @return Whether the apk field is set.
*/
public fun hasApk(): kotlin.Boolean {
return _builder.hasApk()
}
/**
* <code>uint64 size = 8;</code>
*/
public var size: kotlin.Long
@JvmName("getSize")
get() = _builder.getSize()
@JvmName("setSize")
set(value) {
_builder.setSize(value)
}
/**
* <code>uint64 size = 8;</code>
*/
public fun clearSize() {
_builder.clearSize()
}
}
}
@kotlin.jvm.JvmName("-initializeapk")
public inline fun apk(block: com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Apk =
com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Apk.newBuilder()).apply { block() }._build()
public object ApkKt {
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
@com.google.protobuf.kotlin.ProtoDslMarker
public class Dsl private constructor(
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Apk.Builder
) {
public companion object {
@kotlin.jvm.JvmSynthetic
@kotlin.PublishedApi
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Apk.Builder): Dsl = Dsl(builder)
}
@kotlin.jvm.JvmSynthetic
@kotlin.PublishedApi
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Apk = _builder.build()
/**
* <pre>
**
* Attention: Has default value of 0
* </pre>
*
* <code>uint64 versionCode = 1;</code>
*/
public var versionCode: kotlin.Long
@JvmName("getVersionCode")
get() = _builder.getVersionCode()
@JvmName("setVersionCode")
set(value) {
_builder.setVersionCode(value)
}
/**
* <pre>
**
* Attention: Has default value of 0
* </pre>
*
* <code>uint64 versionCode = 1;</code>
*/
public fun clearVersionCode() {
_builder.clearVersionCode()
}
/**
* <code>string installer = 2;</code>
*/
public var installer: kotlin.String
@JvmName("getInstaller")
get() = _builder.getInstaller()
@JvmName("setInstaller")
set(value) {
_builder.setInstaller(value)
}
/**
* <code>string installer = 2;</code>
*/
public fun clearInstaller() {
_builder.clearInstaller()
}
/**
* An uninstantiable, behaviorless type to represent the field in
* generics.
*/
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
public class SignaturesProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
/**
* <code>repeated bytes signatures = 3;</code>
*/
public val signatures: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>
@kotlin.jvm.JvmSynthetic
get() = com.google.protobuf.kotlin.DslList(
_builder.getSignaturesList()
)
/**
* <code>repeated bytes signatures = 3;</code>
* @param value The signatures to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addSignatures")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.add(value: com.google.protobuf.ByteString) {
_builder.addSignatures(value)
}/**
* <code>repeated bytes signatures = 3;</code>
* @param value The signatures to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignSignatures")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.plusAssign(value: com.google.protobuf.ByteString) {
add(value)
}/**
* <code>repeated bytes signatures = 3;</code>
* @param values The signatures to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addAllSignatures")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
_builder.addAllSignatures(values)
}/**
* <code>repeated bytes signatures = 3;</code>
* @param values The signatures to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignAllSignatures")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
addAll(values)
}/**
* <code>repeated bytes signatures = 3;</code>
* @param index The index to set the value at.
* @param value The signatures to set.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("setSignatures")
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
_builder.setSignatures(index, value)
}/**
* <code>repeated bytes signatures = 3;</code>
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("clearSignatures")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, SignaturesProxy>.clear() {
_builder.clearSignatures()
}
/**
* An uninstantiable, behaviorless type to represent the field in
* generics.
*/
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
public class SplitsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
/**
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
*/
public val splits: com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>
@kotlin.jvm.JvmSynthetic
get() = com.google.protobuf.kotlin.DslList(
_builder.getSplitsList()
)
/**
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
* @param value The splits to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addSplits")
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.add(value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
_builder.addSplits(value)
}
/**
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
* @param value The splits to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignSplits")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.plusAssign(value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
add(value)
}
/**
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
* @param values The splits to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addAllSplits")
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.addAll(values: kotlin.collections.Iterable<com.stevesoltys.seedvault.proto.Snapshot.Split>) {
_builder.addAllSplits(values)
}
/**
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
* @param values The splits to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignAllSplits")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.plusAssign(values: kotlin.collections.Iterable<com.stevesoltys.seedvault.proto.Snapshot.Split>) {
addAll(values)
}
/**
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
* @param index The index to set the value at.
* @param value The splits to set.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("setSplits")
public operator fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.set(index: kotlin.Int, value: com.stevesoltys.seedvault.proto.Snapshot.Split) {
_builder.setSplits(index, value)
}
/**
* <code>repeated .com.stevesoltys.seedvault.proto.Snapshot.Split splits = 4;</code>
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("clearSplits")
public fun com.google.protobuf.kotlin.DslList<com.stevesoltys.seedvault.proto.Snapshot.Split, SplitsProxy>.clear() {
_builder.clearSplits()
}
}
}
@kotlin.jvm.JvmName("-initializesplit")
public inline fun split(block: com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Split =
com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Split.newBuilder()).apply { block() }._build()
public object SplitKt {
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
@com.google.protobuf.kotlin.ProtoDslMarker
public class Dsl private constructor(
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Split.Builder
) {
public companion object {
@kotlin.jvm.JvmSynthetic
@kotlin.PublishedApi
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Split.Builder): Dsl = Dsl(builder)
}
@kotlin.jvm.JvmSynthetic
@kotlin.PublishedApi
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Split = _builder.build()
/**
* <code>string name = 1;</code>
*/
public var name: kotlin.String
@JvmName("getName")
get() = _builder.getName()
@JvmName("setName")
set(value) {
_builder.setName(value)
}
/**
* <code>string name = 1;</code>
*/
public fun clearName() {
_builder.clearName()
}
/**
* An uninstantiable, behaviorless type to represent the field in
* generics.
*/
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
public class ChunkIdsProxy private constructor() : com.google.protobuf.kotlin.DslProxy()
/**
* <code>repeated bytes chunkIds = 2;</code>
*/
public val chunkIds: com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>
@kotlin.jvm.JvmSynthetic
get() = com.google.protobuf.kotlin.DslList(
_builder.getChunkIdsList()
)
/**
* <code>repeated bytes chunkIds = 2;</code>
* @param value The chunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addChunkIds")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.add(value: com.google.protobuf.ByteString) {
_builder.addChunkIds(value)
}/**
* <code>repeated bytes chunkIds = 2;</code>
* @param value The chunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignChunkIds")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(value: com.google.protobuf.ByteString) {
add(value)
}/**
* <code>repeated bytes chunkIds = 2;</code>
* @param values The chunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("addAllChunkIds")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.addAll(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
_builder.addAllChunkIds(values)
}/**
* <code>repeated bytes chunkIds = 2;</code>
* @param values The chunkIds to add.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("plusAssignAllChunkIds")
@Suppress("NOTHING_TO_INLINE")
public inline operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.plusAssign(values: kotlin.collections.Iterable<com.google.protobuf.ByteString>) {
addAll(values)
}/**
* <code>repeated bytes chunkIds = 2;</code>
* @param index The index to set the value at.
* @param value The chunkIds to set.
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("setChunkIds")
public operator fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.set(index: kotlin.Int, value: com.google.protobuf.ByteString) {
_builder.setChunkIds(index, value)
}/**
* <code>repeated bytes chunkIds = 2;</code>
*/
@kotlin.jvm.JvmSynthetic
@kotlin.jvm.JvmName("clearChunkIds")
public fun com.google.protobuf.kotlin.DslList<com.google.protobuf.ByteString, ChunkIdsProxy>.clear() {
_builder.clearChunkIds()
}}
}
@kotlin.jvm.JvmName("-initializeblob")
public inline fun blob(block: com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Blob =
com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl._create(com.stevesoltys.seedvault.proto.Snapshot.Blob.newBuilder()).apply { block() }._build()
public object BlobKt {
@kotlin.OptIn(com.google.protobuf.kotlin.OnlyForUseByGeneratedProtoCode::class)
@com.google.protobuf.kotlin.ProtoDslMarker
public class Dsl private constructor(
private val _builder: com.stevesoltys.seedvault.proto.Snapshot.Blob.Builder
) {
public companion object {
@kotlin.jvm.JvmSynthetic
@kotlin.PublishedApi
internal fun _create(builder: com.stevesoltys.seedvault.proto.Snapshot.Blob.Builder): Dsl = Dsl(builder)
}
@kotlin.jvm.JvmSynthetic
@kotlin.PublishedApi
internal fun _build(): com.stevesoltys.seedvault.proto.Snapshot.Blob = _builder.build()
/**
* <code>bytes id = 1;</code>
*/
public var id: com.google.protobuf.ByteString
@JvmName("getId")
get() = _builder.getId()
@JvmName("setId")
set(value) {
_builder.setId(value)
}
/**
* <code>bytes id = 1;</code>
*/
public fun clearId() {
_builder.clearId()
}
/**
* <code>uint32 length = 2;</code>
*/
public var length: kotlin.Int
@JvmName("getLength")
get() = _builder.getLength()
@JvmName("setLength")
set(value) {
_builder.setLength(value)
}
/**
* <code>uint32 length = 2;</code>
*/
public fun clearLength() {
_builder.clearLength()
}
/**
* <code>uint32 uncompressedLength = 3;</code>
*/
public var uncompressedLength: kotlin.Int
@JvmName("getUncompressedLength")
get() = _builder.getUncompressedLength()
@JvmName("setUncompressedLength")
set(value) {
_builder.setUncompressedLength(value)
}
/**
* <code>uint32 uncompressedLength = 3;</code>
*/
public fun clearUncompressedLength() {
_builder.clearUncompressedLength()
}
}
}
}
public inline fun com.stevesoltys.seedvault.proto.Snapshot.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot =
com.stevesoltys.seedvault.proto.SnapshotKt.Dsl._create(this.toBuilder()).apply { block() }._build()
public inline fun com.stevesoltys.seedvault.proto.Snapshot.App.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.App =
com.stevesoltys.seedvault.proto.SnapshotKt.AppKt.Dsl._create(this.toBuilder()).apply { block() }._build()
public val com.stevesoltys.seedvault.proto.Snapshot.AppOrBuilder.apkOrNull: com.stevesoltys.seedvault.proto.Snapshot.Apk?
get() = if (hasApk()) getApk() else null
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Apk.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Apk =
com.stevesoltys.seedvault.proto.SnapshotKt.ApkKt.Dsl._create(this.toBuilder()).apply { block() }._build()
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Split.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Split =
com.stevesoltys.seedvault.proto.SnapshotKt.SplitKt.Dsl._create(this.toBuilder()).apply { block() }._build()
public inline fun com.stevesoltys.seedvault.proto.Snapshot.Blob.copy(block: com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl.() -> kotlin.Unit): com.stevesoltys.seedvault.proto.Snapshot.Blob =
com.stevesoltys.seedvault.proto.SnapshotKt.BlobKt.Dsl._create(this.toBuilder()).apply { block() }._build()

View file

@ -32,16 +32,16 @@ class KoinInstrumentationTestApp : App() {
val testModule = module { val testModule = module {
val context = this@KoinInstrumentationTestApp val context = this@KoinInstrumentationTestApp
single { spyk(PackageService(context, get(), get(), 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(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_FULL
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
import java.nio.ByteBuffer import java.nio.ByteBuffer
internal const val VERSION: Byte = 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 =

View file

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

View file

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

View file

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

View file

@ -56,7 +56,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
if (version == 0.toByte()) return readMetadataV0(inputStream, expectedToken) if (version == 0.toByte()) return readMetadataV0(inputStream, expectedToken)
val metadataBytes = try { val metadataBytes = try {
crypto.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,
) )

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,12 +23,12 @@ import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.PackageState import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -84,7 +84,7 @@ internal class InstallProgressAdapter(
if (item.icon == null) iconJob = scope.launch { if (item.icon == null) iconJob = scope.launch {
iconLoader(item, appIcon::setImageDrawable) iconLoader(item, appIcon::setImageDrawable)
} else appIcon.setImageDrawable(item.icon) } else appIcon.setImageDrawable(item.icon)
appName.text = item.name ?: getAppName(v.context, item.packageName.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 -> {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
/** /**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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