Merge branch 'android14' of https://github.com/seedvault-app/seedvault into leaf-3.2

Change-Id: I06ac6c688f6d274518957cba87c935c193d8754a
diff --git a/.cirrus.yml b/.cirrus.yml
index 613c4b0..d150203 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -7,7 +7,7 @@
     cpu: 8
     memory: 32G
   build_script:
-    - ./.github/scripts/build_aosp.sh aosp_arm64 userdebug android-14.0.0_r1
+    - ./.github/scripts/build_aosp.sh aosp_arm64 ap1a userdebug android-14.0.0_r29
   always:
     seedvault_artifacts:
       path: Seedvault.apk
diff --git a/.github/scripts/build_aosp.sh b/.github/scripts/build_aosp.sh
index 1cd2e4c..4e94b71 100755
--- a/.github/scripts/build_aosp.sh
+++ b/.github/scripts/build_aosp.sh
@@ -52,8 +52,9 @@
 }
 
 DEVICE=$1
-TARGET=$2
-BRANCH=$3
+RELEASE=$2
+TARGET=$3
+BRANCH=$4
 
 git config --global user.email "seedvault@example.com"
 git config --global user.name "Seedvault CI"
@@ -85,7 +86,8 @@
 while true; do echo "Still building..."; sleep 30; done &
 
 source build/envsetup.sh
-lunch $DEVICE-$TARGET
-m -j6 Seedvault
+lunch $DEVICE-$RELEASE-$TARGET
+m -j1 nothing
+m -j2 Seedvault
 
 mv /aosp/out/target/product/generic_arm64/system/system_ext/priv-app/Seedvault/Seedvault.apk "$CIRRUS_WORKING_DIR"
diff --git a/.github/scripts/run_tests.sh b/.github/scripts/run_tests.sh
index 48a8657..b680a24 100755
--- a/.github/scripts/run_tests.sh
+++ b/.github/scripts/run_tests.sh
@@ -14,8 +14,10 @@
 sleep 10
 adb shell bmgr transport com.stevesoltys.seedvault.transport.ConfigurableBackupTransport
 
+D2D_BACKUP_TEST=$1
+
 large_test_exit_code=0
-./gradlew --stacktrace -Pinstrumented_test_size=large :app:connectedAndroidTest || large_test_exit_code=$?
+./gradlew --stacktrace -Pinstrumented_test_size=large -Pd2d_backup_test="$D2D_BACKUP_TEST" :app:connectedAndroidTest || large_test_exit_code=$?
 
 adb pull /sdcard/seedvault_test_results
 
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 52dd750..3f2e548 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,10 +1,15 @@
 name: Build
-on: [push, pull_request]
+on: [ push, pull_request ]
 
 concurrency:
   group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
   cancel-in-progress: true
 
+permissions:
+  contents: read
+  actions: read
+  checks: write
+
 jobs:
   build:
     name: Build
@@ -40,3 +45,10 @@
             app/build/outputs/apk/debug/app-debug.apk
             contactsbackup/build/outputs/apk/debug/contactsbackup-debug.apk
             storage/demo/build/outputs/apk/debug/demo-debug.apk
+
+      - name: Publish Test Report
+        uses: mikepenz/action-junit-report@v4
+        if: success() || failure()
+        with:
+          report_paths: '**/build/test-results/**/TEST-*.xml'
+
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a624c77..bf8aa1b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -19,7 +19,8 @@
       fail-fast: false
       matrix:
         android_target: [ 33, 34 ]
-        emulator_type: [ default ]
+        emulator_type: [ aosp_atd ]
+        d2d_backup_test: [ true, false ]
     steps:
       - name: Checkout Code
         uses: actions/checkout@v3
@@ -52,7 +53,7 @@
             disable-animations: true
             script: |
               ./app/development/scripts/provision_emulator.sh "test" "system-images;android-${{ matrix.android_target }};${{ matrix.emulator_type }};x86_64"
-              ./.github/scripts/run_tests.sh
+              ./.github/scripts/run_tests.sh ${{ matrix.d2d_backup_test }}
 
       - name: Upload test results
         if: always()
diff --git a/.gitignore b/.gitignore
index 7688171..4a365e3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@
 
 ## Intellij
 out/
+build/
+storage/build/
+contactsbackup/build/
 /lib/
 .idea/*
 !.idea/runConfigurations*
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 5edad4f..2933213 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -138,4 +138,4 @@
       </indentOptions>
     </codeStyleSettings>
   </code_scheme>
-</component>
\ No newline at end of file
+</component>
diff --git a/Android.bp b/Android.bp
index 04c3cc1..d61e977 100644
--- a/Android.bp
+++ b/Android.bp
@@ -30,6 +30,7 @@
         "androidx.activity_activity-ktx",
         "androidx.preference_preference",
         "androidx.documentfile_documentfile",
+        "androidx.work_work-runtime-ktx",
         "androidx.lifecycle_lifecycle-viewmodel-ktx",
         "androidx.lifecycle_lifecycle-livedata-ktx",
         "androidx-constraintlayout_constraintlayout",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 559d3ea..39cbc66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+## [14-4.0] - 2024-01-24
+* Add experimental support for forcing "D2D" transfer backups
+* Pretend to be a device-to-device transfer to allow backing up many apps which prevent backup
+* Stop backing up excluded app APKs
+* Show size of app backups in Backup Status screen
+* Slight improvements to color scheme
+* Development: Improve CI testing setup
+
 ## [14-3.3] - 2023-10-06
 * Android 14
 
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index fcb46e4..0000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,243 +0,0 @@
-plugins {
-    id 'com.android.application'
-    id 'org.jetbrains.kotlin.android'
-    id 'org.jlleitschuh.gradle.ktlint'
-}
-
-def gitDescribe = { ->
-    def stdout = new ByteArrayOutputStream()
-    exec {
-        commandLine 'git', 'describe', '--always', '--tags', '--dirty=-dirty'
-        standardOutput = stdout
-    }
-    return stdout.toString().trim()
-}
-
-android {
-    namespace 'com.stevesoltys.seedvault'
-    compileSdk rootProject.ext.compileSdk
-
-    defaultConfig {
-        minSdk rootProject.ext.minSdk
-        targetSdk rootProject.ext.targetSdk
-        versionNameSuffix "-$gitDescribe"
-        testInstrumentationRunner "com.stevesoltys.seedvault.KoinInstrumentationTestRunner"
-        testInstrumentationRunnerArguments disableAnalytics: 'true'
-
-        if (project.hasProperty('instrumented_test_size')) {
-            final testSize = project.getProperty('instrumented_test_size')
-            println("Instrumented test size: $testSize")
-
-            testInstrumentationRunnerArguments size: testSize
-        }
-    }
-
-    buildTypes {
-        release {
-            minifyEnabled false
-        }
-    }
-
-    lint {
-        disable "DialogFragmentCallbacksDetector",
-                "InvalidFragmentVersionForActivityResult",
-                "CheckedExceptions"
-        abortOnError true
-    }
-    compileOptions {
-        sourceCompatibility = JavaVersion.VERSION_17
-        targetCompatibility = JavaVersion.VERSION_17
-    }
-    kotlinOptions {
-        jvmTarget = JavaVersion.VERSION_17.toString()
-        languageVersion = "1.8"
-    }
-    packagingOptions {
-        exclude("META-INF/LICENSE.md")
-        exclude("META-INF/LICENSE-notice.md")
-    }
-    testOptions {
-        unitTests.all {
-            useJUnitPlatform()
-            testLogging {
-                events "passed", "skipped", "failed"
-            }
-        }
-        unitTests {
-            includeAndroidResources = true
-        }
-    }
-
-    sourceSets {
-        test {
-            java.srcDirs += "$projectDir/src/sharedTest/java"
-        }
-        androidTest {
-            java.srcDirs += "$projectDir/src/sharedTest/java"
-        }
-    }
-
-    signingConfigs {
-        aosp {
-            // Generated from the AOSP platform key:
-            // https://android.googlesource.com/platform/build/+/refs/tags/android-11.0.0_r29/target/product/security/platform.pk8
-            keyAlias "platform"
-            keyPassword "platform"
-            storeFile file("development/platform.jks")
-            storePassword "platform"
-        }
-    }
-
-    buildTypes.release.signingConfig = signingConfigs.aosp
-    buildTypes.debug.signingConfig = signingConfigs.aosp
-}
-
-dependencies {
-    compileOnly rootProject.ext.aosp_libs
-
-    /**
-     * Dependencies in AOSP
-     *
-     * We try to keep the dependencies in sync with what AOSP ships as Seedvault is meant to be built
-     * with the AOSP build system and gradle builds are just for more pleasant development.
-     * Using the AOSP versions in gradle builds allows us to spot issues early on.
-     */
-    implementation rootProject.ext.kotlin_libs.std
-    // These coroutine libraries get upgraded otherwise to versions incompatible with kotlin version
-    implementation rootProject.ext.kotlin_libs.coroutines
-
-    implementation rootProject.ext.std_libs.androidx_core
-    // A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
-    implementation rootProject.ext.std_libs.androidx_fragment
-    implementation rootProject.ext.std_libs.androidx_activity
-    implementation rootProject.ext.std_libs.androidx_preference
-    implementation rootProject.ext.std_libs.androidx_lifecycle_viewmodel_ktx
-    implementation rootProject.ext.std_libs.androidx_lifecycle_livedata_ktx
-    implementation rootProject.ext.std_libs.androidx_constraintlayout
-    implementation rootProject.ext.std_libs.androidx_documentfile
-    implementation rootProject.ext.std_libs.com_google_android_material
-
-    implementation rootProject.ext.storage_libs.com_google_crypto_tink_android
-
-    /**
-     * Storage Dependencies
-     */
-    implementation project(':storage:lib')
-
-    /**
-     * External Dependencies
-     *
-     * If the dependencies below are updated,
-     * please make sure to update the prebuilt libraries and the Android.bp files
-     * in the top-level `libs` folder to reflect that.
-     * You can copy these libraries from ~/.gradle/caches/modules-2/files-2.1
-     */
-    // later versions than 2.1.1 require newer kotlin version
-//    implementation "io.insert-koin:koin-core-jvm:3.2.0"
-//    implementation "io.insert-koin:koin-android:3.2.0"
-    implementation fileTree(include: ['*.jar'], dir: "${rootProject.rootDir}/libs/koin-android")
-    implementation fileTree(include: ['*.aar'], dir: "${rootProject.rootDir}/libs/koin-android")
-
-//    implementation "cash.z.ecc.android:kotlin-bip39:1.0.6"
-    implementation fileTree(include: ['kotlin-bip39-jvm-1.0.6.jar'], dir: "${rootProject.rootDir}/libs")
-
-    /**
-     * Test Dependencies (do not concern the AOSP build)
-     */
-    lintChecks rootProject.ext.lint_libs.exceptions
-
-    // anything less than 'implementation' fails tests run with gradlew
-    testImplementation rootProject.ext.aosp_libs
-    testImplementation 'androidx.test.ext:junit:1.1.5'
-    testImplementation('org.robolectric:robolectric:4.10.3')
-    testImplementation 'org.hamcrest:hamcrest:2.2'
-    testImplementation "org.junit.jupiter:junit-jupiter-api:$junit5_version"
-    testImplementation "org.junit.jupiter:junit-jupiter-params:$junit5_version"
-    testImplementation "io.mockk:mockk:$mockk_version"
-    testImplementation 'org.bitcoinj:bitcoinj-core:0.16.2'
-    testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit5_version"
-    testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit5_version"
-
-    androidTestImplementation rootProject.ext.aosp_libs
-    androidTestImplementation 'androidx.test:runner:1.4.0'
-    androidTestImplementation 'androidx.test:rules:1.4.0'
-    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
-    androidTestImplementation "io.mockk:mockk-android:1.13.8"
-    androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
-}
-
-apply from: "${rootProject.rootDir}/gradle/ktlint.gradle"
-
-gradle.projectsEvaluated {
-    tasks.withType(JavaCompile) {
-        options.compilerArgs.add('-Xbootclasspath/p:app/libs/android.jar:app/libs/libcore.jar')
-    }
-}
-
-tasks.withType(Test).configureEach {
-    testLogging {
-        showExceptions true
-        showCauses true
-        showStackTraces true
-
-        exceptionFormat = 'full'
-    }
-}
-
-configurations {
-    all {
-        resolutionStrategy {
-            failOnNonReproducibleResolution()
-        }
-    }
-}
-
-tasks.register('provisionEmulator', Exec) {
-    group("emulator")
-
-    dependsOn(tasks.assembleRelease)
-
-    doFirst {
-        commandLine "${project.projectDir}/development/scripts/provision_emulator.sh",
-                "seedvault",
-                "system-images;android-34;default;x86_64"
-
-        environment "ANDROID_HOME", android.sdkDirectory.absolutePath
-        environment "JAVA_HOME", System.properties['java.home']
-    }
-}
-
-tasks.register('startEmulator', Exec) {
-    group("emulator")
-
-    doFirst {
-        commandLine "${project.projectDir}/development/scripts/start_emulator.sh", "seedvault"
-
-        environment "ANDROID_HOME", android.sdkDirectory.absolutePath
-        environment "JAVA_HOME", System.properties['java.home']
-    }
-}
-
-tasks.register('installEmulatorRelease', Exec) {
-    group("emulator")
-
-    dependsOn(tasks.assembleRelease)
-
-    doFirst {
-        commandLine "${project.projectDir}/development/scripts/install_app.sh"
-
-        environment "ANDROID_HOME", android.sdkDirectory.absolutePath
-        environment "JAVA_HOME", System.properties['java.home']
-    }
-}
-
-tasks.register('clearEmulatorAppData', Exec) {
-    group("emulator")
-
-    doFirst {
-        commandLine "${project.projectDir}/development/scripts/clear_app_data.sh"
-
-        environment "ANDROID_HOME", android.sdkDirectory.absolutePath
-        environment "JAVA_HOME", System.properties['java.home']
-    }
-}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..ec43fa2
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,260 @@
+import org.gradle.api.tasks.testing.logging.TestExceptionFormat
+import java.io.ByteArrayOutputStream
+
+plugins {
+    id("com.android.application")
+    kotlin("android")
+}
+
+val gitDescribe = {
+    val stdout = ByteArrayOutputStream()
+    exec {
+        commandLine("git", "describe", "--always", "--tags", "--dirty=-dirty")
+        standardOutput = stdout
+    }
+    stdout.toString().trim()
+}
+
+android {
+    namespace = "com.stevesoltys.seedvault"
+    compileSdk = libs.versions.compileSdk.get().toInt()
+
+    defaultConfig {
+        minSdk = libs.versions.minSdk.get().toInt()
+        targetSdk = libs.versions.targetSdk.get().toInt()
+        versionNameSuffix = "-${gitDescribe()}"
+        testInstrumentationRunner = "com.stevesoltys.seedvault.KoinInstrumentationTestRunner"
+        testInstrumentationRunnerArguments["disableAnalytics"] = "true"
+
+        if (project.hasProperty("instrumented_test_size")) {
+            val testSize = project.property("instrumented_test_size").toString()
+            println("Instrumented test size: $testSize")
+
+            testInstrumentationRunnerArguments["size"] = testSize
+        }
+
+        val d2dBackupTest = project.findProperty("d2d_backup_test")?.toString() ?: "true"
+        testInstrumentationRunnerArguments["d2d_backup_test"] = d2dBackupTest
+    }
+
+    signingConfigs {
+        create("aosp") {
+            // Generated from the AOSP platform key:
+            // https://android.googlesource.com/platform/build/+/refs/tags/android-11.0.0_r29/target/product/security/platform.pk8
+            keyAlias = "platform"
+            keyPassword = "platform"
+            storeFile = file("development/platform.jks")
+            storePassword = "platform"
+        }
+    }
+
+    buildTypes {
+        all {
+            isMinifyEnabled = false
+        }
+
+        getByName("release").signingConfig = signingConfigs.getByName("aosp")
+        getByName("debug").signingConfig = signingConfigs.getByName("aosp")
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.toString()
+        languageVersion = "1.8"
+    }
+
+    packagingOptions {
+        exclude("META-INF/LICENSE.md")
+        exclude("META-INF/LICENSE-notice.md")
+    }
+
+    testOptions.unitTests {
+        all { it.useJUnitPlatform() }
+
+        isIncludeAndroidResources = true
+    }
+
+    sourceSets {
+        named("test") {
+            java.srcDirs("$projectDir/src/sharedTest/java")
+        }
+        named("androidTest") {
+            java.srcDirs("$projectDir/src/sharedTest/java")
+        }
+    }
+
+    lint {
+        abortOnError = true
+
+        disable.clear()
+        disable += setOf(
+            "DialogFragmentCallbacksDetector",
+            "InvalidFragmentVersionForActivityResult",
+            "CheckedExceptions"
+        )
+    }
+}
+
+dependencies {
+
+    val aospLibs = fileTree("$projectDir/libs") {
+        // For more information about this module:
+        // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507
+        // framework_intermediates/classes-header.jar works for gradle build as well,
+        // but not unit tests, so we use the actual classes (without updatable modules).
+        //
+        // out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
+        include("android.jar")
+        // out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art_intermediates/classes.jar
+        include("libcore.jar")
+    }
+
+    compileOnly(aospLibs)
+
+    /**
+     * Dependencies in AOSP
+     *
+     * We try to keep the dependencies in sync with what AOSP ships as Seedvault is meant to be built
+     * with the AOSP build system and gradle builds are just for more pleasant development.
+     * Using the AOSP versions in gradle builds allows us to spot issues early on.
+     */
+    implementation(libs.bundles.kotlin)
+    // These coroutine libraries get upgraded otherwise to versions incompatible with kotlin version
+    implementation(libs.bundles.coroutines)
+
+    implementation(libs.androidx.core)
+    // A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
+    implementation(libs.androidx.fragment)
+    implementation(libs.androidx.activity)
+    implementation(libs.androidx.preference)
+    implementation(libs.androidx.lifecycle.viewmodel.ktx)
+    implementation(libs.androidx.lifecycle.livedata.ktx)
+    implementation(libs.androidx.constraintlayout)
+    implementation(libs.androidx.documentfile)
+    implementation(libs.androidx.work.runtime.ktx)
+    implementation(libs.google.material)
+
+    implementation(libs.google.tink.android)
+
+    /**
+     * Storage Dependencies
+     */
+    implementation(project(":storage:lib"))
+
+    /**
+     * External Dependencies
+     *
+     * If the dependencies below are updated,
+     * please make sure to update the prebuilt libraries and the Android.bp files
+     * in the top-level `libs` folder to reflect that.
+     * You can copy these libraries from ~/.gradle/caches/modules-2/files-2.1
+     */
+    // later versions than 2.1.1 require newer kotlin version
+    implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.jar"))
+    implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.aar"))
+
+    implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
+
+    /**
+     * Test Dependencies (do not concern the AOSP build)
+     */
+    lintChecks(libs.thirdegg.lint.rules)
+
+    // anything less than 'implementation' fails tests run with gradlew
+    testImplementation(aospLibs)
+    testImplementation("androidx.test.ext:junit:1.1.5")
+    testImplementation("org.robolectric:robolectric:4.10.3")
+    testImplementation("org.hamcrest:hamcrest:2.2")
+    testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}")
+    testImplementation("org.junit.jupiter:junit-jupiter-params:${libs.versions.junit5.get()}")
+    testImplementation("io.mockk:mockk:${libs.versions.mockk.get()}")
+    testImplementation("org.bitcoinj:bitcoinj-core:0.16.2")
+    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${libs.versions.junit5.get()}")
+    testRuntimeOnly("org.junit.vintage:junit-vintage-engine:${libs.versions.junit5.get()}")
+
+    androidTestImplementation(aospLibs)
+    androidTestImplementation("androidx.test:runner:1.4.0")
+    androidTestImplementation("androidx.test:rules:1.4.0")
+    androidTestImplementation("androidx.test.ext:junit:1.1.3")
+    androidTestImplementation("io.mockk:mockk-android:1.13.8")
+    androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0")
+}
+
+gradle.projectsEvaluated {
+    tasks.withType(JavaCompile::class) {
+        options.compilerArgs.add("-Xbootclasspath/p:app/libs/android.jar:app/libs/libcore.jar")
+    }
+}
+
+tasks.withType<Test>().configureEach {
+    testLogging {
+        events("passed", "skipped", "failed")
+
+        showExceptions = true
+        showCauses = true
+        showStackTraces = true
+        exceptionFormat = TestExceptionFormat.FULL
+    }
+}
+
+configurations.all {
+    resolutionStrategy {
+        failOnNonReproducibleResolution()
+    }
+}
+
+tasks.register<Exec>("provisionEmulator") {
+    group = "emulator"
+
+    dependsOn(tasks.getByName("assembleRelease"))
+
+    doFirst {
+        commandLine(
+            "${project.projectDir}/development/scripts/provision_emulator.sh",
+            "seedvault",
+            "system-images;android-34;default;x86_64"
+        )
+
+        environment("ANDROID_HOME", android.sdkDirectory.absolutePath)
+        environment("JAVA_HOME", System.getProperty("java.home"))
+    }
+}
+
+tasks.register<Exec>("startEmulator") {
+    group = "emulator"
+
+    doFirst {
+        commandLine("${project.projectDir}/development/scripts/start_emulator.sh", "seedvault")
+
+        environment("ANDROID_HOME", android.sdkDirectory.absolutePath)
+        environment("JAVA_HOME", System.getProperty("java.home"))
+    }
+}
+
+tasks.register<Exec>("installEmulatorRelease") {
+    group = "emulator"
+
+    dependsOn(tasks.getByName("assembleRelease"))
+
+    doFirst {
+        commandLine("${project.projectDir}/development/scripts/install_app.sh")
+
+        environment("ANDROID_HOME", android.sdkDirectory.absolutePath)
+        environment("JAVA_HOME", System.getProperty("java.home"))
+    }
+}
+
+tasks.register<Exec>("clearEmulatorAppData") {
+    group = "emulator"
+
+    doFirst {
+        commandLine("${project.projectDir}/development/scripts/clear_app_data.sh")
+
+        environment("ANDROID_HOME", android.sdkDirectory.absolutePath)
+        environment("JAVA_HOME", System.getProperty("java.home"))
+    }
+}
diff --git a/app/development/scripts/provision_emulator.sh b/app/development/scripts/provision_emulator.sh
index fef04b8..284e708 100755
--- a/app/development/scripts/provision_emulator.sh
+++ b/app/development/scripts/provision_emulator.sh
@@ -84,7 +84,7 @@
 
 if [ ! -f backup.tar.gz ]; then
   echo "Downloading test backup..."
-  wget --quiet https://github.com/seedvault-app/seedvault-test-data/releases/download/1/backup.tar.gz
+  wget --quiet https://github.com/seedvault-app/seedvault-test-data/releases/download/3/backup.tar.gz
 fi
 
 $ADB root
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt
index d3eff96..c00438f 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt
@@ -1,9 +1,11 @@
 package com.stevesoltys.seedvault
 
 import com.stevesoltys.seedvault.restore.RestoreViewModel
+import com.stevesoltys.seedvault.settings.SettingsManager
 import com.stevesoltys.seedvault.transport.backup.FullBackup
 import com.stevesoltys.seedvault.transport.backup.InputFactory
 import com.stevesoltys.seedvault.transport.backup.KVBackup
+import com.stevesoltys.seedvault.transport.backup.PackageService
 import com.stevesoltys.seedvault.transport.restore.FullRestore
 import com.stevesoltys.seedvault.transport.restore.KVRestore
 import com.stevesoltys.seedvault.transport.restore.OutputFactory
@@ -25,6 +27,9 @@
         val testModule = module {
             val context = this@KoinInstrumentationTestApp
 
+            single { spyk(PackageService(context, get(), get(), get())) }
+            single { spyk(SettingsManager(context)) }
+
             single { spyk(BackupNotificationManager(context)) }
             single { spyk(FullBackup(get(), get(), get(), get())) }
             single { spyk(KVBackup(get(), get(), get(), get(), get())) }
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt
index 70f5897..82d2e49 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt
@@ -1,6 +1,5 @@
 package com.stevesoltys.seedvault.e2e
 
-import android.app.backup.IBackupManager
 import android.content.pm.PackageInfo
 import android.os.ParcelFileDescriptor
 import com.stevesoltys.seedvault.e2e.io.BackupDataInputIntercept
@@ -26,8 +25,6 @@
         private const val BACKUP_TIMEOUT = 360 * 1000L
     }
 
-    val backupManager: IBackupManager get() = get()
-
     val spyBackupNotificationManager: BackupNotificationManager get() = get()
 
     val spyFullBackup: FullBackup get() = get()
@@ -122,9 +119,21 @@
         coEvery {
             spyKVBackup.finishBackup()
         } answers {
+            val oldMap = HashMap<String, String>()
+            // @pm@ and android can get backed up multiple times (if we need more than one request)
+            // so we need to keep the data it backed up before
+            if (backupResult.kv.containsKey(packageName)) {
+                backupResult.kv[packageName]?.forEach { (key, value) ->
+                    // if a key existing in new data, we use its value from new data, don't override
+                    if (!data.containsKey(key)) oldMap[key] = value
+                }
+            }
             backupResult.kv[packageName!!] = data
                 .mapValues { entry -> entry.value.sha256() }
                 .toMutableMap()
+                .apply {
+                    putAll(oldMap)
+                }
 
             packageName = null
             data = mutableMapOf()
@@ -170,7 +179,7 @@
         clearMocks(spyBackupNotificationManager)
 
         every {
-            spyBackupNotificationManager.onBackupFinished(any(), any())
+            spyBackupNotificationManager.onBackupFinished(any(), any(), any())
         } answers {
             val success = firstArg<Boolean>()
             assert(success) { "Backup failed." }
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt
index 3d74aed..be95877 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeRestoreTestBase.kt
@@ -173,6 +173,10 @@
         coEvery {
             spyFullRestore.initializeState(any(), any(), any(), any())
         } answers {
+            packageName?.let {
+                restoreResult.full[it] = dataIntercept.toByteArray().sha256()
+            }
+
             packageName = arg<PackageInfo>(3).packageName
             dataIntercept = ByteArrayOutputStream()
 
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt
index 86f14a2..69d0cf6 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeTestBase.kt
@@ -1,6 +1,7 @@
 package com.stevesoltys.seedvault.e2e
 
 import android.app.UiAutomation
+import android.app.backup.IBackupManager
 import android.content.Context
 import android.content.pm.PackageInfo
 import android.content.pm.PackageManager.PERMISSION_GRANTED
@@ -72,6 +73,8 @@
 
     val spyMetadataManager: MetadataManager get() = get()
 
+    val backupManager: IBackupManager get() = get()
+
     val spyRestoreViewModel: RestoreViewModel
         get() = currentRestoreViewModel ?: error("currentRestoreViewModel is null")
 
@@ -79,6 +82,7 @@
         get() = currentRestoreStorageViewModel ?: error("currentRestoreStorageViewModel is null")
 
     fun resetApplicationState() {
+        backupManager.setAutoRestore(false)
         settingsManager.setNewToken(null)
         documentsStorage.reset(null)
 
@@ -95,6 +99,7 @@
         }
 
         clearDocumentPickerAppData()
+        device.executeShellCommand("rm -R $externalStorageDir/.SeedVaultAndroidBackup")
     }
 
     fun waitUntilIdle() {
@@ -157,6 +162,7 @@
 
     fun clearTestBackups() {
         File(testStoragePath).deleteRecursively()
+        File(testVideoPath).deleteRecursively()
     }
 
     fun changeBackupLocation(
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt
index 2d2be5f..e2fa7d9 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTest.kt
@@ -2,6 +2,7 @@
 
 import android.content.pm.PackageManager
 import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
 import kotlinx.coroutines.runBlocking
 import org.junit.After
 import org.junit.Before
@@ -40,6 +41,17 @@
 
         startRecordingTest(keepRecordingScreen, name.methodName)
         restoreBaselineBackup()
+
+        val arguments = InstrumentationRegistry.getArguments()
+
+        if (arguments.getString("d2d_backup_test") == "true") {
+            println("Enabling D2D backups for test")
+            settingsManager.setD2dBackupsEnabled(true)
+
+        } else {
+            println("Disabling D2D backups for test")
+            settingsManager.setD2dBackupsEnabled(false)
+        }
     }
 
     @After
@@ -63,10 +75,14 @@
             val extDir = externalStorageDir
 
             device.executeShellCommand("rm -R $extDir/.SeedVaultAndroidBackup")
-            device.executeShellCommand("cp -R $extDir/$BASELINE_BACKUP_FOLDER/" +
-                ".SeedVaultAndroidBackup $extDir")
-            device.executeShellCommand("cp -R $extDir/$BASELINE_BACKUP_FOLDER/" +
-                "recovery-code.txt $extDir")
+            device.executeShellCommand(
+                "cp -R $extDir/$BASELINE_BACKUP_FOLDER/" +
+                    ".SeedVaultAndroidBackup $extDir"
+            )
+            device.executeShellCommand(
+                "cp -R $extDir/$BASELINE_BACKUP_FOLDER/" +
+                    "recovery-code.txt $extDir"
+            )
         }
 
         if (backupFile.exists()) {
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTestResult.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTestResult.kt
index 3223aa5..4c5e3b6 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTestResult.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/SeedvaultLargeTestResult.kt
@@ -2,6 +2,7 @@
 
 import android.content.pm.PackageInfo
 import com.stevesoltys.seedvault.metadata.PackageMetadata
+import com.stevesoltys.seedvault.restore.AppRestoreResult
 
 /**
  * Contains maps of (package name -> SHA-256 hashes) of application data.
@@ -12,8 +13,9 @@
  * For full backups, the mapping is: Map<PackageName, SHA-256>
  * For K/V backups, the mapping is: Map<PackageName, Map<Key, SHA-256>>
  */
-data class SeedvaultLargeTestResult(
+internal data class SeedvaultLargeTestResult(
     val backupResults: Map<String, PackageMetadata?> = emptyMap(),
+    val restoreResults: Map<String, AppRestoreResult?> = emptyMap(),
     val full: MutableMap<String, String>,
     val kv: MutableMap<String, MutableMap<String, String>>,
     val userApps: List<PackageInfo>,
diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt
index f140eb2..ca54566 100644
--- a/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt
+++ b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt
@@ -1,8 +1,16 @@
 package com.stevesoltys.seedvault.transport.backup
 
+import android.content.pm.PackageInfo
 import android.util.Log
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.MediumTest
+import com.stevesoltys.seedvault.plugins.StoragePlugin
+import com.stevesoltys.seedvault.settings.AppStatus
+import com.stevesoltys.seedvault.settings.SettingsManager
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.koin.core.component.KoinComponent
@@ -14,10 +22,41 @@
 
     private val packageService: PackageService by inject()
 
+    private val settingsManager: SettingsManager by inject()
+
+    private val storagePlugin: StoragePlugin by inject()
+
     @Test
     fun testNotAllowedPackages() {
         val packages = packageService.notBackedUpPackages
         Log.e("TEST", "Packages: $packages")
     }
 
+    @Test
+    fun `shouldIncludeAppInBackup exempts plugin provider and blacklisted apps`() {
+        val packageInfo = PackageInfo().apply {
+            packageName = "com.example"
+        }
+
+        val disabledAppStatus = mockk<AppStatus>().apply {
+            every { packageName } returns packageInfo.packageName
+            every { enabled } returns false
+        }
+        settingsManager.onAppBackupStatusChanged(disabledAppStatus)
+
+        // Should not backup blacklisted apps
+        assertFalse(packageService.shouldIncludeAppInBackup(packageInfo.packageName))
+
+        val enabledAppStatus = mockk<AppStatus>().apply {
+            every { packageName } returns packageInfo.packageName
+            every { enabled } returns true
+        }
+        settingsManager.onAppBackupStatusChanged(enabledAppStatus)
+
+        // Should backup non-blacklisted apps
+        assertTrue(packageService.shouldIncludeAppInBackup(packageInfo.packageName))
+
+        // Should not backup storage provider
+        assertFalse(packageService.shouldIncludeAppInBackup(storagePlugin.providerPackageName!!))
+    }
 }
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 57c91c0..6bc9213 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,8 +2,8 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
     package="com.stevesoltys.seedvault"
-    android:versionCode="34030030"
-    android:versionName="14-3.3">
+    android:versionCode="34040000"
+    android:versionName="14-4.0">
     <!--
     The version code is the targeted SDK_VERSION plus 6 digits for our own version code.
     The version name is the targeted Android version followed by - and our own version name.
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index 508d3fe..c0cef4a 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/App.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt
@@ -5,12 +5,11 @@
 import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
 import android.app.backup.IBackupManager
 import android.content.Context
-import android.content.Context.BACKUP_SERVICE
 import android.content.pm.PackageManager.PERMISSION_GRANTED
 import android.os.Build
 import android.os.ServiceManager.getService
 import android.os.StrictMode
-import android.os.UserHandle
+import android.os.UserManager
 import com.stevesoltys.seedvault.crypto.cryptoModule
 import com.stevesoltys.seedvault.header.headerModule
 import com.stevesoltys.seedvault.metadata.MetadataManager
@@ -142,8 +141,11 @@
     }
 }
 
-fun Context.getSystemContext(isUsbStorage: () -> Boolean): Context {
-    return if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED &&
-        isUsbStorage()
-    ) createContextAsUser(UserHandle.SYSTEM, 0) else this
+@Suppress("MissingPermission")
+fun Context.getStorageContext(isUsbStorage: () -> Boolean): Context {
+    if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED && isUsbStorage()) {
+        UserManager.get(this).getProfileParent(user)
+            ?.let { parent -> return createContextAsUser(parent, 0) }
+    }
+    return this
 }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/BackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/BackupWorker.kt
new file mode 100644
index 0000000..0244616
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/BackupWorker.kt
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.stevesoltys.seedvault
+
+import android.content.Context
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.stevesoltys.seedvault.transport.requestBackup
+import java.util.concurrent.TimeUnit
+
+class BackupWorker(
+    appContext: Context,
+    workerParams: WorkerParameters,
+) : Worker(appContext, workerParams) {
+
+    companion object {
+        private const val UNIQUE_WORK_NAME = "APP_BACKUP"
+
+        fun schedule(appContext: Context) {
+            val backupConstraints = Constraints.Builder()
+                .setRequiredNetworkType(NetworkType.UNMETERED)
+                .setRequiresCharging(true)
+                .build()
+            val backupWorkRequest = PeriodicWorkRequestBuilder<BackupWorker>(
+                repeatInterval = 24,
+                repeatIntervalTimeUnit = TimeUnit.HOURS,
+                flexTimeInterval = 2,
+                flexTimeIntervalUnit = TimeUnit.HOURS,
+            ).setConstraints(backupConstraints)
+                .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS)
+                .build()
+            val workManager = WorkManager.getInstance(appContext)
+            workManager.enqueueUniquePeriodicWork(UNIQUE_WORK_NAME, UPDATE, backupWorkRequest)
+        }
+
+        fun unschedule(appContext: Context) {
+            val workManager = WorkManager.getInstance(appContext)
+            workManager.cancelUniqueWork(UNIQUE_WORK_NAME)
+        }
+    }
+
+    override fun doWork(): Result {
+        // TODO once we make this the default, we should do storage backup here as well
+        //  or have two workers and ensure they never run at the same time
+        return if (requestBackup(applicationContext)) Result.success()
+        else Result.retry()
+    }
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt
index c480833..01a1026 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt
@@ -18,6 +18,7 @@
     internal val androidVersion: Int = Build.VERSION.SDK_INT,
     internal val androidIncremental: String = Build.VERSION.INCREMENTAL,
     internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}",
+    internal var d2dBackup: Boolean = false,
     internal val packageMetadataMap: PackageMetadataMap = PackageMetadataMap(),
 )
 
@@ -29,6 +30,7 @@
 internal const val JSON_METADATA_SDK_INT = "sdk_int"
 internal const val JSON_METADATA_INCREMENTAL = "incremental"
 internal const val JSON_METADATA_NAME = "name"
+internal const val JSON_METADATA_D2D_BACKUP = "d2d_backup"
 
 enum class PackageState {
     /**
@@ -72,6 +74,7 @@
     internal var time: Long = 0L,
     internal var state: PackageState = UNKNOWN_ERROR,
     internal var backupType: BackupType? = null,
+    internal var size: Long? = null,
     internal val system: Boolean = false,
     internal val version: Long? = null,
     internal val installer: String? = null,
@@ -95,6 +98,7 @@
 internal const val JSON_PACKAGE_TIME = "time"
 internal const val JSON_PACKAGE_BACKUP_TYPE = "backupType"
 internal const val JSON_PACKAGE_STATE = "state"
+internal const val JSON_PACKAGE_SIZE = "size"
 internal const val JSON_PACKAGE_SYSTEM = "system"
 internal const val JSON_PACKAGE_VERSION = "version"
 internal const val JSON_PACKAGE_INSTALLER = "installer"
diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt
index 98a57f4..0dc2663 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt
@@ -17,6 +17,7 @@
 import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
 import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
 import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
+import com.stevesoltys.seedvault.settings.SettingsManager
 import com.stevesoltys.seedvault.transport.backup.isSystemApp
 import java.io.FileNotFoundException
 import java.io.IOException
@@ -35,6 +36,7 @@
     private val crypto: Crypto,
     private val metadataWriter: MetadataWriter,
     private val metadataReader: MetadataReader,
+    private val settingsManager: SettingsManager
 ) {
 
     private val uninitializedMetadata = BackupMetadata(token = 0L, salt = "")
@@ -44,8 +46,12 @@
                 field = try {
                     getMetadataFromCache() ?: throw IOException()
                 } catch (e: IOException) {
-                    // If this happens, it is hard to recover from this. Let's hope it never does.
-                    throw AssertionError("Error reading metadata from cache", e)
+                    // This can happen if the storage location ran out of space
+                    // or the app process got killed while writing the file.
+                    // It is hard to recover from this, so we try as best as we can here:
+                    Log.e(TAG, "ERROR getting metadata cache, creating new file ", e)
+                    // This should cause requiresInit() return true
+                    uninitializedMetadata.copy(version = (-1).toByte())
                 }
                 mLastBackupTime.postValue(field.time)
             }
@@ -129,22 +135,28 @@
     fun onPackageBackedUp(
         packageInfo: PackageInfo,
         type: BackupType,
+        size: Long?,
         metadataOutputStream: OutputStream,
     ) {
         val packageName = packageInfo.packageName
         modifyMetadata(metadataOutputStream) {
             val now = clock.time()
             metadata.time = now
+            metadata.d2dBackup = settingsManager.d2dBackupsEnabled()
+
             if (metadata.packageMetadataMap.containsKey(packageName)) {
                 metadata.packageMetadataMap[packageName]!!.time = now
                 metadata.packageMetadataMap[packageName]!!.state = APK_AND_DATA
                 metadata.packageMetadataMap[packageName]!!.backupType = type
+                // don't override a previous K/V size, if there were no K/V changes
+                if (size != null) metadata.packageMetadataMap[packageName]!!.size = size
             } else {
                 metadata.packageMetadataMap[packageName] = PackageMetadata(
                     time = now,
                     state = APK_AND_DATA,
                     backupType = type,
-                    system = packageInfo.isSystemApp()
+                    size = size,
+                    system = packageInfo.isSystemApp(),
                 )
             }
         }
@@ -243,6 +255,11 @@
     }
 
     @Synchronized
+    fun getPackagesBackupSize(): Long {
+        return metadata.packageMetadataMap.values.sumOf { it.size ?: 0L }
+    }
+
+    @Synchronized
     @VisibleForTesting
     private fun getMetadataFromCache(): BackupMetadata? {
         try {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataModule.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataModule.kt
index 68c723a..0d7ed1a 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataModule.kt
@@ -4,7 +4,7 @@
 import org.koin.dsl.module
 
 val metadataModule = module {
-    single { MetadataManager(androidContext(), get(), get(), get(), get()) }
+    single { MetadataManager(androidContext(), get(), get(), get(), get(), get()) }
     single<MetadataWriter> { MetadataWriterImpl(get()) }
     single<MetadataReader> { MetadataReaderImpl(get()) }
 }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt
index 382a175..fe1920b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt
@@ -120,6 +120,7 @@
                     // because when only backing up the APK for example, there's no type
                     else -> null
                 }
+                val pSize = p.optLong(JSON_PACKAGE_SIZE, -1L)
                 val pSystem = p.optBoolean(JSON_PACKAGE_SYSTEM, false)
                 val pVersion = p.optLong(JSON_PACKAGE_VERSION, 0L)
                 val pInstaller = p.optString(JSON_PACKAGE_INSTALLER)
@@ -136,6 +137,7 @@
                     time = p.getLong(JSON_PACKAGE_TIME),
                     state = pState,
                     backupType = pBackupType,
+                    size = if (pSize < 0L) null else pSize,
                     system = pSystem,
                     version = if (pVersion == 0L) null else pVersion,
                     installer = if (pInstaller == "") null else pInstaller,
@@ -152,7 +154,8 @@
                 androidVersion = meta.getInt(JSON_METADATA_SDK_INT),
                 androidIncremental = meta.getString(JSON_METADATA_INCREMENTAL),
                 deviceName = meta.getString(JSON_METADATA_NAME),
-                packageMetadataMap = packageMetadataMap
+                d2dBackup = meta.optBoolean(JSON_METADATA_D2D_BACKUP, false),
+                packageMetadataMap = packageMetadataMap,
             )
         } catch (e: JSONException) {
             throw SecurityException(e)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt
index 1359c11..ef9473b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt
@@ -35,6 +35,7 @@
                 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) {
@@ -48,6 +49,9 @@
                 if (packageMetadata.backupType != null) {
                     put(JSON_PACKAGE_BACKUP_TYPE, packageMetadata.backupType!!.name)
                 }
+                if (packageMetadata.size != null) {
+                    put(JSON_PACKAGE_SIZE, packageMetadata.size)
+                }
                 if (packageMetadata.system) {
                     put(JSON_PACKAGE_SYSTEM, packageMetadata.system)
                 }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt
index 94ff8ad..0dbc6c5 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt
@@ -4,7 +4,7 @@
 import android.content.pm.PackageManager
 import android.util.Log
 import androidx.documentfile.provider.DocumentFile
-import com.stevesoltys.seedvault.getSystemContext
+import com.stevesoltys.seedvault.getStorageContext
 import com.stevesoltys.seedvault.plugins.EncryptedMetadata
 import com.stevesoltys.seedvault.plugins.StoragePlugin
 import com.stevesoltys.seedvault.settings.Storage
@@ -25,7 +25,7 @@
      * Attention: This context might be from a different user. Use with care.
      */
     private val context: Context
-        get() = appContext.getSystemContext {
+        get() = appContext.getStorageContext {
             storage.storage?.isUsb == true
         }
 
@@ -83,7 +83,7 @@
     @Throws(IOException::class)
     override suspend fun hasBackup(storage: Storage): Boolean {
         // potentially get system user context if needed here
-        val c = appContext.getSystemContext { storage.isUsb }
+        val c = appContext.getStorageContext { storage.isUsb }
         val parent = DocumentFile.fromTreeUri(c, storage.uri) ?: throw AssertionError()
         val rootDir = parent.findFileBlocking(c, DIRECTORY_ROOT) ?: return false
         val backupSets = getBackups(c, rootDir)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt
index 2ae9699..593f494 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt
@@ -16,7 +16,7 @@
 import android.util.Log
 import androidx.annotation.VisibleForTesting
 import androidx.documentfile.provider.DocumentFile
-import com.stevesoltys.seedvault.getSystemContext
+import com.stevesoltys.seedvault.getStorageContext
 import com.stevesoltys.seedvault.settings.SettingsManager
 import com.stevesoltys.seedvault.settings.Storage
 import kotlinx.coroutines.TimeoutCancellationException
@@ -55,7 +55,7 @@
      * Attention: This context might be from a different user. Use with care.
      */
     private val context: Context
-        get() = appContext.getSystemContext {
+        get() = appContext.getStorageContext {
             storage?.isUsb == true
         }
     private val contentResolver: ContentResolver get() = context.contentResolver
@@ -134,12 +134,22 @@
 
     @Throws(IOException::class)
     fun getInputStream(file: DocumentFile): InputStream {
-        return contentResolver.openInputStream(file.uri) ?: throw IOException()
+        return try {
+            contentResolver.openInputStream(file.uri) ?: throw IOException()
+        } catch (e: Exception) {
+            // SAF can throw all sorts of exceptions, so wrap it in IOException
+            throw IOException(e)
+        }
     }
 
     @Throws(IOException::class)
     fun getOutputStream(file: DocumentFile): OutputStream {
-        return contentResolver.openOutputStream(file.uri, "wt") ?: throw IOException()
+        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)
+        }
     }
 
 }
@@ -161,8 +171,10 @@
                 throw IOException("File named ${this.name}, but should be $name")
             }
         } ?: throw IOException()
-    } catch (e: IllegalArgumentException) {
-        // Can be thrown by FileSystemProvider#isChildDocument() when flash drive is not plugged-in
+    } catch (e: Exception) {
+        // SAF can throw all sorts of exceptions, so wrap it in IOException.
+        // E.g. IllegalArgumentException can be thrown by FileSystemProvider#isChildDocument()
+        // when flash drive is not plugged-in:
         // http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135
         throw IOException(e)
     }
@@ -248,7 +260,7 @@
 suspend fun DocumentFile.findFileBlocking(context: Context, displayName: String): DocumentFile? {
     val files = try {
         listFilesBlocking(context)
-    } catch (e: IOException) {
+    } catch (e: Exception) {
         Log.e(TAG, "Error finding file blocking", e)
         return null
     }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt
index f1c33c1..2c3abb3 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt
@@ -23,6 +23,9 @@
     val deviceName: String
         get() = backupMetadata.deviceName
 
+    val d2dBackup: Boolean
+        get() = backupMetadata.d2dBackup
+
     val packageMetadataMap: PackageMetadataMap
         get() = backupMetadata.packageMetadataMap
 
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
index 531ce03..861598b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreFilesFragment.kt
@@ -24,14 +24,14 @@
     ): View {
         val v = super.onCreateView(inflater, container, savedInstanceState)
 
-        val topStub: ViewStub = v.findViewById(R.id.topStub)
+        val topStub: ViewStub = v.requireViewById(R.id.topStub)
         topStub.layoutResource = R.layout.header_snapshots
         topStub.inflate()
 
-        val bottomStub: ViewStub = v.findViewById(R.id.bottomStub)
+        val bottomStub: ViewStub = v.requireViewById(R.id.bottomStub)
         bottomStub.layoutResource = R.layout.footer_snapshots
         val footer = bottomStub.inflate()
-        val skipView: TextView = footer.findViewById(R.id.skipView)
+        val skipView: TextView = footer.requireViewById(R.id.skipView)
         skipView.setOnClickListener {
             requireActivity().apply {
                 setResult(RESULT_OK)
@@ -54,7 +54,7 @@
     ): View {
         val v: View = inflater.inflate(R.layout.fragment_restore_files_started, container, false)
 
-        val button: Button = v.findViewById(R.id.button)
+        val button: Button = v.requireViewById(R.id.button)
         button.setOnClickListener {
             requireActivity().apply {
                 setResult(RESULT_OK)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
index 17bf9fc..472776d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreProgressFragment.kt
@@ -37,11 +37,11 @@
     ): View {
         val v: View = inflater.inflate(R.layout.fragment_restore_progress, container, false)
 
-        progressBar = v.findViewById(R.id.progressBar)
-        titleView = v.findViewById(R.id.titleView)
-        backupNameView = v.findViewById(R.id.backupNameView)
-        appList = v.findViewById(R.id.appList)
-        button = v.findViewById(R.id.button)
+        progressBar = v.requireViewById(R.id.progressBar)
+        titleView = v.requireViewById(R.id.titleView)
+        backupNameView = v.requireViewById(R.id.backupNameView)
+        appList = v.requireViewById(R.id.appList)
+        button = v.requireViewById(R.id.button)
 
         return v
     }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
index f7e7cbb..f55ccbe 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetAdapter.kt
@@ -31,8 +31,8 @@
 
     inner class RestoreSetViewHolder(private val v: View) : ViewHolder(v) {
 
-        private val titleView = v.findViewById<TextView>(R.id.titleView)
-        private val subtitleView = v.findViewById<TextView>(R.id.subtitleView)
+        private val titleView = v.requireViewById<TextView>(R.id.titleView)
+        private val subtitleView = v.requireViewById<TextView>(R.id.subtitleView)
 
         internal fun bind(item: RestorableBackup) {
             v.setOnClickListener { listener.onRestorableBackupClicked(item) }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
index 6959565..14c248a 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreSetFragment.kt
@@ -30,10 +30,10 @@
     ): View {
         val v: View = inflater.inflate(R.layout.fragment_restore_set, container, false)
 
-        listView = v.findViewById(R.id.listView)
-        progressBar = v.findViewById(R.id.progressBar)
-        errorView = v.findViewById(R.id.errorView)
-        skipView = v.findViewById(R.id.skipView)
+        listView = v.requireViewById(R.id.listView)
+        progressBar = v.requireViewById(R.id.progressBar)
+        errorView = v.requireViewById(R.id.errorView)
+        skipView = v.requireViewById(R.id.skipView)
 
         return v
     }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
index 59556ad..87d4b01 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt
@@ -1,6 +1,8 @@
 package com.stevesoltys.seedvault.restore
 
 import android.app.Application
+import android.app.backup.BackupManager
+import android.app.backup.BackupTransport
 import android.app.backup.IBackupManager
 import android.app.backup.IRestoreObserver
 import android.app.backup.IRestoreSession
@@ -13,8 +15,8 @@
 import androidx.annotation.WorkerThread
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.Transformations.switchMap
 import androidx.lifecycle.asLiveData
+import androidx.lifecycle.switchMap
 import androidx.lifecycle.viewModelScope
 import com.stevesoltys.seedvault.BackupMonitor
 import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
@@ -37,6 +39,7 @@
 import com.stevesoltys.seedvault.settings.SettingsManager
 import com.stevesoltys.seedvault.storage.StorageRestoreService
 import com.stevesoltys.seedvault.transport.TRANSPORT_ID
+import com.stevesoltys.seedvault.transport.backup.NUM_PACKAGES_PER_TRANSACTION
 import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
 import com.stevesoltys.seedvault.ui.AppBackupState
 import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
@@ -63,10 +66,13 @@
 import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTAMP_START
 import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_USER_ID
 import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
+import java.lang.IllegalStateException
 import java.util.LinkedList
 
 private val TAG = RestoreViewModel::class.java.simpleName
 
+internal const val PACKAGES_PER_CHUNK = NUM_PACKAGES_PER_TRANSACTION
+
 internal class RestoreViewModel(
     app: Application,
     settingsManager: SettingsManager,
@@ -94,7 +100,7 @@
     internal val chosenRestorableBackup: LiveData<RestorableBackup> get() = mChosenRestorableBackup
 
     internal val installResult: LiveData<InstallResult> =
-        switchMap(mChosenRestorableBackup) { backup ->
+        mChosenRestorableBackup.switchMap { backup ->
             getInstallResult(backup)
         }
     internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) }
@@ -137,6 +143,7 @@
                     Log.d(TAG, "Ignoring RestoreSet with no last backup time: $token.")
                     null
                 }
+
                 else -> RestorableBackup(metadata)
             }
         }
@@ -149,7 +156,6 @@
     }
 
     override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) {
-        restoreCoordinator.beforeStartRestore(restorableBackup.backupMetadata)
         mChosenRestorableBackup.value = restorableBackup
         mDisplayFragment.setEvent(RESTORE_APPS)
     }
@@ -173,14 +179,17 @@
 
     internal fun onNextClickedAfterInstallingApps() {
         mDisplayFragment.postEvent(RESTORE_BACKUP)
-        val token = mChosenRestorableBackup.value?.token ?: throw AssertionError()
+
         viewModelScope.launch(ioDispatcher) {
-            startRestore(token)
+            startRestore()
         }
     }
 
     @WorkerThread
-    private fun startRestore(token: Long) {
+    private fun startRestore() {
+        val token = mChosenRestorableBackup.value?.token
+            ?: throw IllegalStateException("No chosen backup")
+
         Log.d(TAG, "Starting new restore session to restore backup $token")
 
         // if we had no token before (i.e. restore from setup wizard),
@@ -200,21 +209,29 @@
             return
         }
 
-        // we need to retrieve the restore sets before starting the restore
-        // otherwise restoreAll() won't work as they need the restore sets cached internally
-        val observer = RestoreObserver { observer ->
-            // this lambda gets executed after we got the restore sets
-            // now we can start the restore of all available packages
-            val restoreAllResult = session.restoreAll(token, observer, monitor)
-            if (restoreAllResult != 0) {
-                Log.e(TAG, "restoreAll() returned non-zero value")
+        val restorableBackup = mChosenRestorableBackup.value
+        val packages = restorableBackup?.packageMetadataMap?.keys?.toList()
+            ?: run {
+                Log.e(TAG, "Chosen backup has empty package metadata map")
                 mRestoreBackupResult.postValue(
-                    RestoreBackupResult(app.getString(R.string.restore_finished_error))
+                    RestoreBackupResult(app.getString(R.string.restore_set_error))
                 )
+                return
             }
-        }
+
+        val observer = RestoreObserver(
+            restoreCoordinator = restoreCoordinator,
+            restorableBackup = restorableBackup,
+            session = session,
+            packages = packages,
+            monitor = monitor
+        )
+
+        // We need to retrieve the restore sets before starting the restore.
+        // Otherwise, restorePackages() won't work as they need the restore sets cached internally.
         if (session.getAvailableRestoreSets(observer, monitor) != 0) {
             Log.e(TAG, "getAvailableRestoreSets() returned non-zero value")
+
             mRestoreBackupResult.postValue(
                 RestoreBackupResult(app.getString(R.string.restore_set_error))
             )
@@ -229,6 +246,7 @@
 
         // check previous package first and change status
         updateLatestPackage(list)
+
         // add current package
         list.addFirst(AppRestoreResult(packageName, getAppName(app, packageName), IN_PROGRESS))
         mRestoreProgress.postValue(list)
@@ -294,8 +312,27 @@
     }
 
     @WorkerThread
-    private inner class RestoreObserver(private val startRestore: (RestoreObserver) -> Unit) :
-        IRestoreObserver.Stub() {
+    private inner class RestoreObserver(
+        private val restoreCoordinator: RestoreCoordinator,
+        private val restorableBackup: RestorableBackup,
+        private val session: IRestoreSession,
+        private val packages: List<String>,
+        private val monitor: BackupMonitor,
+    ) : IRestoreObserver.Stub() {
+
+        /**
+         * The current package index.
+         *
+         * Used for splitting the packages into chunks.
+         */
+        private var packageIndex: Int = 0
+
+        /**
+         * Map of results for each chunk.
+         *
+         * The key is the chunk index, the value is the result.
+         */
+        private val chunkResults = mutableMapOf<Int, Int>()
 
         /**
          * Supply a list of the restore datasets available from the current transport.
@@ -307,7 +344,33 @@
          *   the current device. If no applicable datasets exist, restoreSets will be null.
          */
         override fun restoreSetsAvailable(restoreSets: Array<out RestoreSet>?) {
-            startRestore(this)
+            // this gets executed after we got the restore sets
+            // now we can start the restore of all available packages
+            restoreNextPackages()
+        }
+
+        /**
+         * Restore the next chunk of packages.
+         *
+         * We need to restore in chunks, otherwise [BackupTransport.startRestore] in the
+         * framework's [PerformUnifiedRestoreTask] may fail due to an oversize Binder
+         * transaction, causing the entire restoration to fail.
+         */
+        private fun restoreNextPackages() {
+            // Make sure metadata for selected backup is cached before starting each chunk.
+            val backupMetadata = restorableBackup.backupMetadata
+            restoreCoordinator.beforeStartRestore(backupMetadata)
+
+            val nextChunkIndex = (packageIndex + PACKAGES_PER_CHUNK).coerceAtMost(packages.size)
+            val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
+            packageIndex += packageChunk.size
+
+            val token = backupMetadata.token
+            val result = session.restorePackages(token, this, packageChunk, monitor)
+
+            if (result != BackupManager.SUCCESS) {
+                Log.e(TAG, "restorePackages() returned non-zero value: $result")
+            }
         }
 
         /**
@@ -341,14 +404,35 @@
          *   as a whole failed.
          */
         override fun restoreFinished(result: Int) {
-            val restoreResult = RestoreBackupResult(
-                if (result == 0) null
-                else app.getString(R.string.restore_finished_error)
-            )
-            onRestoreComplete(restoreResult)
+            val chunkIndex = packageIndex / PACKAGES_PER_CHUNK
+            chunkResults[chunkIndex] = result
+
+            // Restore next chunk if successful and there are more packages to restore.
+            if (packageIndex < packages.size) {
+                restoreNextPackages()
+                return
+            }
+
+            // Restore finished, time to get the result.
+            onRestoreComplete(getRestoreResult())
             closeSession()
         }
 
+        private fun getRestoreResult(): RestoreBackupResult {
+            val failedChunks = chunkResults
+                .filter { it.value != BackupManager.SUCCESS }
+                .map { "chunk ${it.key} failed with error ${it.value}" }
+
+            return if (failedChunks.isNotEmpty()) {
+                Log.e(TAG, "Restore failed: $failedChunks")
+
+                return RestoreBackupResult(
+                    errorMsg = app.getString(R.string.restore_finished_error)
+                )
+            } else {
+                RestoreBackupResult(errorMsg = null)
+            }
+        }
     }
 
     @UiThread
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt
index 59600b3..a553ce8 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt
@@ -76,6 +76,9 @@
             } catch (e: TimeoutCancellationException) {
                 Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
                 emit(installResult.fail(packageName))
+            } catch (e: Exception) {
+                Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
+                emit(installResult.fail(packageName))
             }
         }
         installResult.isFinished = true
@@ -126,13 +129,13 @@
         }
 
         // get app icon and label (name)
-        val appInfo = packageInfo.applicationInfo.apply {
+        val appInfo = packageInfo.applicationInfo?.apply {
             // set APK paths before, so package manager can find it for icon extraction
             sourceDir = cachedApk.absolutePath
             publicSourceDir = cachedApk.absolutePath
         }
-        val icon = appInfo.loadIcon(pm)
-        val name = pm.getApplicationLabel(appInfo)
+        val icon = appInfo?.loadIcon(pm)
+        val name = appInfo?.let { pm.getApplicationLabel(it) }
 
         installResult.update(packageName) { result ->
             result.copy(state = IN_PROGRESS, name = name, icon = icon)
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallIntentCreator.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallIntentCreator.kt
index fb9572b..1f9b13a 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallIntentCreator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallIntentCreator.kt
@@ -14,6 +14,7 @@
     private val installerToPackage = mapOf(
         "org.fdroid.fdroid" to "org.fdroid.fdroid",
         "org.fdroid.fdroid.privileged" to "org.fdroid.fdroid",
+        "org.fdroid.basic" to "org.fdroid.basic",
         "com.aurora.store" to "com.aurora.store",
         "com.aurora.services" to "com.aurora.store",
         "com.android.vending" to "com.android.vending"
diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
index c78d47a..df46838 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallProgressFragment.kt
@@ -41,11 +41,11 @@
     ): View {
         val v: View = inflater.inflate(R.layout.fragment_restore_progress, container, false)
 
-        progressBar = v.findViewById(R.id.progressBar)
-        titleView = v.findViewById(R.id.titleView)
-        backupNameView = v.findViewById(R.id.backupNameView)
-        appList = v.findViewById(R.id.appList)
-        button = v.findViewById(R.id.button)
+        progressBar = v.requireViewById(R.id.progressBar)
+        titleView = v.requireViewById(R.id.titleView)
+        backupNameView = v.requireViewById(R.id.backupNameView)
+        appList = v.requireViewById(R.id.appList)
+        button = v.requireViewById(R.id.button)
 
         return v
     }
@@ -75,7 +75,9 @@
 
     private fun onInstallResult(installResult: InstallResult) {
         // skip this screen, if there are no apps to install
-        if (installResult.isEmpty) viewModel.onNextClickedAfterInstallingApps()
+        if (installResult.isFinished && installResult.isEmpty) {
+            viewModel.onNextClickedAfterInstallingApps()
+        }
 
         // if finished, treat all still queued apps as failed and resort/redisplay adapter items
         if (installResult.isFinished) {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AboutDialogFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AboutDialogFragment.kt
index 81738a2..ea56692 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/AboutDialogFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AboutDialogFragment.kt
@@ -27,12 +27,12 @@
         val v: View = inflater.inflate(R.layout.fragment_about, container, false)
 
         val versionName = packageService.getVersionName(requireContext().packageName) ?: "???"
-        val versionView: TextView = v.findViewById(R.id.versionView)
+        val versionView: TextView = v.requireViewById(R.id.versionView)
         versionView.text = getString(R.string.about_version, versionName)
 
         val linkMovementMethod = LinkMovementMethod.getInstance()
-        val contributorsView = v.findViewById<TextView>(R.id.contributorView)
-        val orgsView = v.findViewById<TextView>(R.id.about_contributing_organizations_content)
+        val contributorsView = v.requireViewById<TextView>(R.id.contributorView)
+        val orgsView = v.requireViewById<TextView>(R.id.about_contributing_organizations_content)
         contributorsView.movementMethod = linkMovementMethod
         orgsView.movementMethod = linkMovementMethod
 
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt
index 29cb923..3bf9445 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt
@@ -38,6 +38,7 @@
     val icon: Drawable,
     val name: String,
     val time: Long,
+    val size: Long?,
     val status: AppBackupState,
     val isSpecial: Boolean = false,
 ) : AppListItem()
@@ -55,9 +56,16 @@
 
     @WorkerThread
     fun getAppList(): List<AppListItem> {
-        return listOf(AppSectionTitle(R.string.backup_section_system)) + getSpecialApps() +
-            listOf(AppSectionTitle(R.string.backup_section_user)) + getUserApps() +
-            listOf(AppSectionTitle(R.string.backup_section_not_allowed)) + getNotAllowedApps()
+
+        val appListSections = linkedMapOf(
+            AppSectionTitle(R.string.backup_section_system) to getSpecialApps(),
+            AppSectionTitle(R.string.backup_section_user) to getUserApps(),
+            AppSectionTitle(R.string.backup_section_not_allowed) to getNotAllowedApps()
+        ).filter { it.value.isNotEmpty() }
+
+        return appListSections.flatMap { (sectionTitle, appList) ->
+            listOf(sectionTitle) + appList
+        }
     }
 
     private fun getSpecialApps(): List<AppListItem> {
@@ -80,6 +88,7 @@
                 icon = getIcon(packageName),
                 name = context.getString(stringId),
                 time = metadata?.time ?: 0,
+                size = metadata?.size,
                 status = status,
                 isSpecial = true
             )
@@ -104,6 +113,7 @@
                 icon = getIcon(it.packageName),
                 name = getAppName(context, it.packageName).toString(),
                 time = time,
+                size = metadata?.size,
                 status = status
             )
         }.sortedBy { it.name.lowercase(locale) }
@@ -118,6 +128,7 @@
                 icon = getIcon(it.packageName),
                 name = getAppName(context, it.packageName).toString(),
                 time = 0,
+                size = null,
                 status = FAILED_NOT_ALLOWED
             )
         }.sortedBy { it.name.lowercase(locale) }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt
index 5536f6b..b4db433 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusAdapter.kt
@@ -3,6 +3,7 @@
 import android.content.Intent
 import android.net.Uri
 import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
+import android.text.format.Formatter.formatShortFileSize
 import android.view.LayoutInflater
 import android.view.View
 import android.view.View.GONE
@@ -116,7 +117,12 @@
                     setState(item.status, false)
                 }
                 if (item.status == SUCCEEDED) {
-                    appInfo.text = item.time.toRelativeTime(context)
+                    appInfo.text = if (item.size == null) {
+                        item.time.toRelativeTime(context)
+                    } else {
+                        item.time.toRelativeTime(context).toString() +
+                            " (${formatShortFileSize(v.context, item.size)})"
+                    }
                     appInfo.visibility = VISIBLE
                 }
                 switchView.visibility = INVISIBLE
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt
index 85c1898..bb678e8 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppStatusFragment.kt
@@ -39,8 +39,8 @@
         setHasOptionsMenu(true)
         val v: View = inflater.inflate(R.layout.fragment_app_status, container, false)
 
-        progressBar = v.findViewById(R.id.progressBar)
-        list = v.findViewById(R.id.list)
+        progressBar = v.requireViewById(R.id.progressBar)
+        list = v.requireViewById(R.id.list)
 
         return v
     }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt
index f4a2eff..b128132 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/ExpertSettingsFragment.kt
@@ -4,6 +4,7 @@
 import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
 import androidx.preference.Preference
 import androidx.preference.PreferenceFragmentCompat
+import androidx.preference.SwitchPreferenceCompat
 import com.stevesoltys.seedvault.R
 import com.stevesoltys.seedvault.permitDiskReads
 import com.stevesoltys.seedvault.transport.backup.PackageService
@@ -14,6 +15,7 @@
 
     private val viewModel: SettingsViewModel by sharedViewModel()
     private val packageService: PackageService by inject()
+
     // TODO set mimeType when upgrading androidx lib
     private val createFileLauncher = registerForActivityResult(CreateDocument()) { uri ->
         viewModel.onLogcatUriReceived(uri)
@@ -23,6 +25,7 @@
         permitDiskReads {
             setPreferencesFromResource(R.xml.settings_expert, rootKey)
         }
+
         findPreference<Preference>("logcat")?.setOnPreferenceClickListener {
             val versionName = packageService.getVersionName(requireContext().packageName) ?: "ver"
             val timestamp = System.currentTimeMillis()
@@ -30,6 +33,26 @@
             createFileLauncher.launch(name)
             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 ->
+            viewModel.onD2dChanged(newValue as Boolean)
+            d2dPreference.isChecked = newValue
+
+            // automatically enable unlimited quota when enabling D2D backups
+            if (d2dPreference.isChecked) {
+                quotaPreference?.isChecked = true
+            }
+            true
+        }
     }
 
     override fun onStart() {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt
index 68194e2..11fc6b5 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsActivity.kt
@@ -61,7 +61,7 @@
         pref: Preference,
     ): Boolean {
         val fragment =
-            supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment)
+            supportFragmentManager.fragmentFactory.instantiate(classLoader, pref.fragment!!)
         if (pref.key == PREF_BACKUP_RECOVERY_CODE) fragment.arguments = Bundle().apply {
             putBoolean(ARG_FOR_NEW_CODE, false)
         }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
index 93ed088..8115c53 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
@@ -89,7 +89,7 @@
             true
         }
 
-        autoRestore = findPreference("auto_restore")!!
+        autoRestore = findPreference(PREF_KEY_AUTO_RESTORE)!!
         autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
             val enabled = newValue as Boolean
             try {
@@ -259,10 +259,14 @@
                 // warn if battery optimization is active
                 // we don't bother with yet another dialog, because the ROM should handle it
                 val context = requireContext()
-                val powerManager = context.getSystemService(PowerManager::class.java)
-                if (!powerManager.isIgnoringBatteryOptimizations(context.packageName)) {
-                    Toast.makeText(context, R.string.settings_backup_storage_battery_optimization,
-                        LENGTH_LONG).show()
+                val powerManager: PowerManager? = context.getSystemService(PowerManager::class.java)
+                if (powerManager != null &&
+                    !powerManager.isIgnoringBatteryOptimizations(context.packageName)
+                ) {
+                    Toast.makeText(
+                        context, R.string.settings_backup_storage_battery_optimization,
+                        LENGTH_LONG
+                    ).show()
                 }
                 viewModel.enableStorageBackup()
                 backupStorage.isChecked = true
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
index e26e61d..47176a0 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
@@ -9,13 +9,14 @@
 import androidx.annotation.WorkerThread
 import androidx.documentfile.provider.DocumentFile
 import androidx.preference.PreferenceManager
-import com.stevesoltys.seedvault.getSystemContext
+import com.stevesoltys.seedvault.getStorageContext
 import com.stevesoltys.seedvault.permitDiskReads
 import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
 import java.util.concurrent.ConcurrentSkipListSet
 
 internal const val PREF_KEY_TOKEN = "token"
 internal const val PREF_KEY_BACKUP_APK = "backup_apk"
+internal const val PREF_KEY_AUTO_RESTORE = "auto_restore"
 
 private const val PREF_KEY_STORAGE_URI = "storageUri"
 private const val PREF_KEY_STORAGE_NAME = "storageName"
@@ -30,7 +31,8 @@
 private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
 
 private const val PREF_KEY_BACKUP_STORAGE = "backup_storage"
-private const val PREF_KEY_UNLIMITED_QUOTA = "unlimited_quota"
+internal const val PREF_KEY_UNLIMITED_QUOTA = "unlimited_quota"
+internal const val PREF_KEY_D2D_BACKUPS = "d2d_backups"
 
 class SettingsManager(private val context: Context) {
 
@@ -131,7 +133,7 @@
     @WorkerThread
     fun canDoBackupNow(): Boolean {
         val storage = getStorage() ?: return false
-        val systemContext = context.getSystemContext { storage.isUsb }
+        val systemContext = context.getStorageContext { storage.isUsb }
         return !storage.isUnavailableUsb(systemContext) && !storage.isUnavailableNetwork(context)
     }
 
@@ -151,6 +153,14 @@
     }
 
     fun isQuotaUnlimited() = prefs.getBoolean(PREF_KEY_UNLIMITED_QUOTA, false)
+
+    fun d2dBackupsEnabled() = prefs.getBoolean(PREF_KEY_D2D_BACKUPS, false)
+
+    fun setD2dBackupsEnabled(enabled: Boolean) {
+        prefs.edit()
+            .putBoolean(PREF_KEY_D2D_BACKUPS, enabled)
+            .apply()
+    }
 }
 
 data class Storage(
@@ -181,7 +191,7 @@
     }
 
     private fun hasUnmeteredInternet(context: Context): Boolean {
-        val cm = context.getSystemService(ConnectivityManager::class.java)
+        val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
         val isMetered = cm.isActiveNetworkMetered
         val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
         return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && !isMetered
diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
index 3c19065..f56faa2 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt
@@ -12,6 +12,7 @@
 import android.net.NetworkRequest
 import android.net.Uri
 import android.os.Process.myUid
+import android.os.UserHandle
 import android.provider.Settings
 import android.util.Log
 import android.widget.Toast
@@ -20,10 +21,11 @@
 import androidx.core.content.ContextCompat.startForegroundService
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.Transformations.switchMap
 import androidx.lifecycle.liveData
+import androidx.lifecycle.switchMap
 import androidx.lifecycle.viewModelScope
 import androidx.recyclerview.widget.DiffUtil.calculateDiff
+import com.stevesoltys.seedvault.BackupWorker
 import com.stevesoltys.seedvault.R
 import com.stevesoltys.seedvault.crypto.KeyManager
 import com.stevesoltys.seedvault.metadata.MetadataManager
@@ -58,7 +60,8 @@
 ) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
 
     private val contentResolver = app.contentResolver
-    private val connectivityManager = app.getSystemService(ConnectivityManager::class.java)
+    private val connectivityManager: ConnectivityManager? =
+        app.getSystemService(ConnectivityManager::class.java)
 
     override val isRestoreOperation = false
 
@@ -67,7 +70,7 @@
 
     internal val lastBackupTime = metadataManager.lastBackupTime
 
-    private val mAppStatusList = switchMap(lastBackupTime) {
+    private val mAppStatusList = lastBackupTime.switchMap {
         // updates app list when lastBackupTime changes
         getAppStatusResult()
     }
@@ -127,13 +130,13 @@
 
         // register network observer if needed
         if (networkCallback.registered && !storage.requiresNetwork) {
-            connectivityManager.unregisterNetworkCallback(networkCallback)
+            connectivityManager?.unregisterNetworkCallback(networkCallback)
             networkCallback.registered = false
         } else if (!networkCallback.registered && storage.requiresNetwork) {
             val request = NetworkRequest.Builder()
                 .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
                 .build()
-            connectivityManager.registerNetworkCallback(request, networkCallback)
+            connectivityManager?.registerNetworkCallback(request, networkCallback)
             networkCallback.registered = true
         }
 
@@ -154,7 +157,7 @@
     override fun onCleared() {
         contentResolver.unregisterContentObserver(storageObserver)
         if (networkCallback.registered) {
-            connectivityManager.unregisterNetworkCallback(networkCallback)
+            connectivityManager?.unregisterNetworkCallback(networkCallback)
             networkCallback.registered = false
         }
     }
@@ -261,4 +264,13 @@
         Toast.makeText(app, str, LENGTH_LONG).show()
     }
 
+    fun onD2dChanged(enabled: Boolean) {
+        backupManager.setFrameworkSchedulingEnabledForUser(UserHandle.myUserId(), !enabled)
+        if (enabled) {
+            BackupWorker.schedule(app)
+        } else {
+            BackupWorker.unschedule(app)
+        }
+    }
+
 }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt
index ca478f1..d965738 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt
@@ -3,7 +3,7 @@
 import android.content.Context
 import androidx.documentfile.provider.DocumentFile
 import com.stevesoltys.seedvault.crypto.KeyManager
-import com.stevesoltys.seedvault.getSystemContext
+import com.stevesoltys.seedvault.getStorageContext
 import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
 import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
 import javax.crypto.SecretKey
@@ -17,7 +17,7 @@
      * Attention: This context might be from a different user. Use with care.
      */
     override val context: Context
-        get() = appContext.getSystemContext {
+        get() = appContext.getStorageContext {
             storage.storage?.isUsb == true
         }
     override val root: DocumentFile
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt
index d34c35a..cead1ea 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt
@@ -1,6 +1,7 @@
 package com.stevesoltys.seedvault.transport
 
 import android.app.backup.BackupAgent.FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED
+import android.app.backup.BackupAgent.FLAG_DEVICE_TO_DEVICE_TRANSFER
 import android.app.backup.BackupTransport
 import android.app.backup.RestoreDescription
 import android.app.backup.RestoreSet
@@ -11,6 +12,7 @@
 import android.util.Log
 import com.stevesoltys.seedvault.R
 import com.stevesoltys.seedvault.settings.SettingsActivity
+import com.stevesoltys.seedvault.settings.SettingsManager
 import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
 import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
 import kotlinx.coroutines.runBlocking
@@ -20,7 +22,8 @@
 // If we ever change this, we should use a ComponentName like the other backup transports.
 val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name
 
-const val TRANSPORT_FLAGS = FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED
+const val DEFAULT_TRANSPORT_FLAGS = FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED
+const val D2D_TRANSPORT_FLAGS = DEFAULT_TRANSPORT_FLAGS or FLAG_DEVICE_TO_DEVICE_TRANSFER
 
 private const val TRANSPORT_DIRECTORY_NAME =
     "com.stevesoltys.seedvault.transport.ConfigurableBackupTransport"
@@ -35,6 +38,7 @@
 
     private val backupCoordinator by inject<BackupCoordinator>()
     private val restoreCoordinator by inject<RestoreCoordinator>()
+    private val settingsManager by inject<SettingsManager>()
 
     override fun transportDirName(): String {
         return TRANSPORT_DIRECTORY_NAME
@@ -54,7 +58,11 @@
      * This allows the agent to decide what to do based on properties of the transport.
      */
     override fun getTransportFlags(): Int {
-        return TRANSPORT_FLAGS
+        return if (settingsManager.d2dBackupsEnabled()) {
+            D2D_TRANSPORT_FLAGS
+        } else {
+            DEFAULT_TRANSPORT_FLAGS
+        }
     }
 
     /**
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt
index 1d67988..1b9fe3b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransportService.kt
@@ -1,19 +1,16 @@
 package com.stevesoltys.seedvault.transport
 
 import android.app.Service
-import android.app.backup.BackupManager
 import android.app.backup.IBackupManager
 import android.content.Context
 import android.content.Intent
 import android.os.IBinder
-import android.os.RemoteException
 import android.util.Log
 import androidx.annotation.WorkerThread
-import com.stevesoltys.seedvault.BackupMonitor
 import com.stevesoltys.seedvault.crypto.KeyManager
+import com.stevesoltys.seedvault.transport.backup.BackupRequester
 import com.stevesoltys.seedvault.transport.backup.PackageService
 import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
-import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
 import org.koin.core.context.GlobalContext.get
@@ -60,29 +57,22 @@
 
 }
 
+/**
+ * Requests the system to initiate a backup.
+ *
+ * @return true iff backups was requested successfully (backup itself can still fail).
+ */
 @WorkerThread
-fun requestBackup(context: Context) {
+fun requestBackup(context: Context): Boolean {
     val backupManager: IBackupManager = get().get()
-    if (backupManager.isBackupEnabled) {
+    return if (backupManager.isBackupEnabled) {
         val packageService: PackageService = get().get()
-        val packages = packageService.eligiblePackages
-        val appTotals = packageService.expectedAppTotals
 
-        val result = try {
-            Log.d(TAG, "Backup is enabled, request backup...")
-            val observer = NotificationBackupObserver(context, packages.size, appTotals)
-            backupManager.requestBackup(packages, observer, BackupMonitor(), 0)
-        } catch (e: RemoteException) {
-            Log.e(TAG, "Error during backup: ", e)
-            val nm: BackupNotificationManager = get().get()
-            nm.onBackupError()
-        }
-        if (result == BackupManager.SUCCESS) {
-            Log.i(TAG, "Backup succeeded ")
-        } else {
-            Log.e(TAG, "Backup failed: $result")
-        }
+        Log.d(TAG, "Backup is enabled, request backup...")
+        val backupRequester = BackupRequester(context, backupManager, packageService)
+        return backupRequester.requestBackup()
     } else {
         Log.i(TAG, "Backup is not enabled")
+        true // this counts as success
     }
 }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt
index 02208eb..c55eb8d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt
@@ -17,7 +17,6 @@
 import com.stevesoltys.seedvault.settings.SettingsManager
 import java.io.File
 import java.io.FileInputStream
-import java.io.FileNotFoundException
 import java.io.IOException
 import java.io.InputStream
 import java.io.OutputStream
@@ -55,6 +54,12 @@
         // do not back up when setting is not enabled
         if (!settingsManager.backupApks()) return null
 
+        // do not back up if package is blacklisted
+        if (!settingsManager.isBackupEnabled(packageName)) {
+            Log.d(TAG, "Package $packageName is blacklisted. Not backing it up.")
+            return null
+        }
+
         // do not back up test-only apps as we can't re-install them anyway
         // see: https://commonsware.com/blog/2017/10/31/android-studio-3p0-flag-test-only.html
         if (packageInfo.isTestOnly()) {
@@ -69,13 +74,14 @@
         }
 
         // TODO remove when adding support for packages with multiple signers
-        if (packageInfo.signingInfo.hasMultipleSigners()) {
+        val signingInfo = packageInfo.signingInfo ?: return null
+        if (signingInfo.hasMultipleSigners()) {
             Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.")
             return null
         }
 
         // get signatures
-        val signatures = packageInfo.signingInfo.getSignatures()
+        val signatures = signingInfo.getSignatures()
         if (signatures.isEmpty()) {
             Log.e(TAG, "Package $packageName has no signatures. Not backing it up.")
             return null
@@ -102,7 +108,8 @@
         }
 
         // get an InputStream for the APK
-        val inputStream = getApkInputStream(packageInfo.applicationInfo.sourceDir)
+        val sourceDir = packageInfo.applicationInfo?.sourceDir ?: return null
+        val inputStream = getApkInputStream(sourceDir)
         // copy the APK to the storage's output and calculate SHA-256 hash while at it
         val name = crypto.getNameForApk(metadataManager.salt, packageName)
         val sha256 = copyStreamsAndGetHash(inputStream, streamGetter(name))
@@ -139,10 +146,8 @@
         val apk = File(apkPath)
         return try {
             apk.inputStream()
-        } catch (e: FileNotFoundException) {
-            Log.e(TAG, "Error opening ${apk.absolutePath} for backup.", e)
-            throw IOException(e)
-        } catch (e: SecurityException) {
+        } catch (e: Exception) {
+            // SAF may throw all sorts of exceptions, so wrap them in IOException
             Log.e(TAG, "Error opening ${apk.absolutePath} for backup.", e)
             throw IOException(e)
         }
@@ -155,7 +160,7 @@
     ): List<ApkSplit> {
         check(packageInfo.splitNames != null)
         // attention: though not documented, splitSourceDirs can be null
-        val splitSourceDirs = packageInfo.applicationInfo.splitSourceDirs ?: emptyArray()
+        val splitSourceDirs = packageInfo.applicationInfo?.splitSourceDirs ?: emptyArray()
         check(packageInfo.splitNames.size == splitSourceDirs.size) {
             "Size Mismatch! ${packageInfo.splitNames.size} != ${splitSourceDirs.size} " +
                 "splitNames is ${packageInfo.splitNames.toList()}, " +
@@ -235,8 +240,10 @@
 /**
  * Returns a list of Base64 encoded SHA-256 signature hashes.
  */
-fun SigningInfo.getSignatures(): List<String> {
-    return if (hasMultipleSigners()) {
+fun SigningInfo?.getSignatures(): List<String> {
+    return if (this == null) {
+        emptyList()
+    } else if (hasMultipleSigners()) {
         apkContentsSigners.map { signature ->
             hashSignature(signature).encodeBase64()
         }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
index 08c56f6..f1dde3b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
@@ -143,12 +143,9 @@
         @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean,
     ): Boolean {
         val packageName = targetPackage.packageName
-        // Check that the app is not blacklisted by the user
-        val enabled = settingsManager.isBackupEnabled(packageName)
-        if (!enabled) Log.w(TAG, "Backup of $packageName disabled by user.")
-        // We need to exclude the DocumentsProvider used to store backup data.
-        // Otherwise, it gets killed when we back it up, terminating our backup.
-        return enabled && targetPackage.packageName != plugin.providerPackageName
+        val shouldInclude = packageService.shouldIncludeAppInBackup(packageName)
+        if (!shouldInclude) Log.i(TAG, "Excluding $packageName from backup.")
+        return shouldInclude
     }
 
     /**
@@ -161,7 +158,8 @@
      */
     suspend fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long {
         if (packageName != MAGIC_PACKAGE_MANAGER) {
-            // try to back up APK here as later methods are sometimes not called called
+            // try to back up APK here as later methods are sometimes not called
+            // TODO move this into BackupWorker
             backUpApk(context.packageManager.getPackageInfo(packageName, GET_SIGNING_CERTIFICATES))
         }
 
@@ -234,6 +232,7 @@
     ): Int {
         state.cancelReason = UNKNOWN_ERROR
         if (metadataManager.requiresInit) {
+            Log.w(TAG, "Metadata requires re-init!")
             // start a new restore set to upgrade from legacy format
             // by starting a clean backup with all files using the new version
             try {
@@ -366,6 +365,7 @@
             // getCurrentPackage() not-null because we have state, call before finishing
             val packageInfo = kv.getCurrentPackage()!!
             val packageName = packageInfo.packageName
+            val size = kv.getCurrentSize()
             // tell K/V backup to finish
             var result = kv.finishBackup()
             if (result == TRANSPORT_OK) {
@@ -373,13 +373,14 @@
                 // call onPackageBackedUp for @pm@ only if we can do backups right now
                 if (!isPmBackup || settingsManager.canDoBackupNow()) {
                     try {
-                        onPackageBackedUp(packageInfo, BackupType.KV)
+                        onPackageBackedUp(packageInfo, BackupType.KV, size)
                     } catch (e: Exception) {
                         Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
                         result = TRANSPORT_PACKAGE_REJECTED
                     }
                 }
                 // hook in here to back up APKs of apps that are otherwise not allowed for backup
+                // TODO move this into BackupWorker
                 if (isPmBackup && settingsManager.canDoBackupNow()) {
                     try {
                         backUpApksOfNotBackedUpPackages()
@@ -399,10 +400,11 @@
             // getCurrentPackage() not-null because we have state
             val packageInfo = full.getCurrentPackage()!!
             val packageName = packageInfo.packageName
+            val size = full.getCurrentSize()
             // tell full backup to finish
             var result = full.finishBackup()
             try {
-                onPackageBackedUp(packageInfo, BackupType.FULL)
+                onPackageBackedUp(packageInfo, BackupType.FULL, size)
             } catch (e: Exception) {
                 Log.e(TAG, "Error calling onPackageBackedUp for $packageName", e)
                 result = TRANSPORT_PACKAGE_REJECTED
@@ -424,10 +426,12 @@
             val packageName = packageInfo.packageName
             try {
                 nm.onOptOutAppBackup(packageName, i + 1, notBackedUpPackages.size)
-                val packageState =
-                    if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED
+                val packageState = if (packageInfo.isStopped()) WAS_STOPPED else NOT_ALLOWED
                 val wasBackedUp = backUpApk(packageInfo, packageState)
-                if (!wasBackedUp) {
+                if (wasBackedUp) {
+                    Log.d(TAG, "Was backed up: $packageName")
+                } else {
+                    Log.d(TAG, "Not backed up: $packageName - ${packageState.name}")
                     val packageMetadata =
                         metadataManager.getPackageMetadata(packageName)
                     val oldPackageState = packageMetadata?.state
@@ -473,9 +477,9 @@
         }
     }
 
-    private suspend fun onPackageBackedUp(packageInfo: PackageInfo, type: BackupType) {
+    private suspend fun onPackageBackedUp(packageInfo: PackageInfo, type: BackupType, size: Long?) {
         plugin.getMetadataOutputStream().use {
-            metadataManager.onPackageBackedUp(packageInfo, type, it)
+            metadataManager.onPackageBackedUp(packageInfo, type, size, it)
         }
     }
 
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
index 8be00ac..bf7d327 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt
@@ -8,7 +8,9 @@
     single {
         PackageService(
             context = androidContext(),
-            backupManager = get()
+            backupManager = get(),
+            settingsManager = get(),
+            plugin = get()
         )
     }
     single {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt
new file mode 100644
index 0000000..79c79ba
--- /dev/null
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupRequester.kt
@@ -0,0 +1,108 @@
+/*
+ * SPDX-FileCopyrightText: 2024 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.stevesoltys.seedvault.transport.backup
+
+import android.app.backup.BackupManager
+import android.app.backup.IBackupManager
+import android.content.Context
+import android.os.RemoteException
+import android.util.Log
+import androidx.annotation.WorkerThread
+import com.stevesoltys.seedvault.BackupMonitor
+import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
+import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver
+import org.koin.core.component.KoinComponent
+import org.koin.core.context.GlobalContext
+
+private val TAG = BackupRequester::class.java.simpleName
+internal const val NUM_PACKAGES_PER_TRANSACTION = 100
+
+/**
+ * Used for requesting a backup of all installed packages,
+ * in chunks if there are more than [NUM_PACKAGES_PER_TRANSACTION].
+ *
+ * Can only be used once for one backup.
+ * Make a new instance for subsequent backups.
+ */
+@WorkerThread
+internal class BackupRequester(
+    context: Context,
+    private val backupManager: IBackupManager,
+    val packageService: PackageService,
+) : KoinComponent {
+
+    private val packages = packageService.eligiblePackages
+    private val observer = NotificationBackupObserver(
+        context = context,
+        backupRequester = this,
+        requestedPackages = packages.size,
+        appTotals = packageService.expectedAppTotals,
+    )
+    private val monitor = BackupMonitor()
+
+    /**
+     * The current package index.
+     *
+     * Used for splitting the packages into chunks.
+     */
+    private var packageIndex: Int = 0
+
+    /**
+     * Request the backup to happen. Should be called short after constructing this object.
+     */
+    fun requestBackup(): Boolean {
+        if (packageIndex != 0) error("requestBackup() called more than once!")
+
+        return request(getNextChunk())
+    }
+
+    /**
+     * Backs up the next chunk of packages.
+     *
+     * @return true, if backup for all packages was already requested and false,
+     * if there are more packages that we just have requested backup for.
+     */
+    fun requestNext(): Boolean {
+        if (packageIndex <= 0) error("requestBackup() must be called first!")
+
+        // Backup next chunk if there are more packages to back up.
+        return if (packageIndex < packages.size) {
+            request(getNextChunk())
+            false
+        } else {
+            true
+        }
+    }
+
+    private fun request(chunk: Array<String>): Boolean {
+        Log.i(TAG, "${chunk.toList()}")
+        val result = try {
+            backupManager.requestBackup(chunk, observer, monitor, 0)
+        } catch (e: RemoteException) {
+            Log.e(TAG, "Error during backup: ", e)
+            val nm: BackupNotificationManager = GlobalContext.get().get()
+            nm.onBackupError()
+        }
+        return if (result == BackupManager.SUCCESS) {
+            Log.i(TAG, "Backup request succeeded")
+            true
+        } else {
+            Log.e(TAG, "Backup request failed: $result")
+            false
+        }
+    }
+
+    private fun getNextChunk(): Array<String> {
+        val nextChunkIndex =
+            (packageIndex + NUM_PACKAGES_PER_TRANSACTION).coerceAtMost(packages.size)
+        val packageChunk = packages.subList(packageIndex, nextChunkIndex).toTypedArray()
+        val numBackingUp = packageIndex + packageChunk.size
+        Log.i(TAG, "Requesting backup for $numBackingUp/${packages.size} packages...")
+        packageIndex += packageChunk.size
+        return packageChunk
+    }
+
+}
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt
index 48a42d6..d44cdeb 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt
@@ -51,6 +51,8 @@
 
     fun getCurrentPackage() = state?.packageInfo
 
+    fun getCurrentSize() = state?.size
+
     fun getQuota(): Long {
         return if (settingsManager.isQuotaUnlimited()) Long.MAX_VALUE else DEFAULT_QUOTA_FULL_BACKUP
     }
@@ -190,7 +192,7 @@
     }
 
     fun finishBackup(): Int {
-        Log.i(TAG, "Finish full backup of ${state!!.packageName}.")
+        Log.i(TAG, "Finish full backup of ${state!!.packageName}. Wrote ${state!!.size} bytes")
         return clearState()
     }
 
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
index 060f543..4467815 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVBackup.kt
@@ -46,6 +46,10 @@
 
     fun getCurrentPackage() = state?.packageInfo
 
+    fun getCurrentSize() = getCurrentPackage()?.let {
+        dbManager.getDbSize(it.packageName)
+    }
+
     fun getQuota(): Long = if (settingsManager.isQuotaUnlimited()) {
         Long.MAX_VALUE
     } else {
@@ -252,7 +256,7 @@
                 }
             }
         }
-        Log.d(TAG, "Uploaded db file for $packageName")
+        Log.d(TAG, "Uploaded db file for $packageName.")
     }
 
     private class KVOperation(
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVDbManager.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVDbManager.kt
index 95a026f..2c2253b 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVDbManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/KVDbManager.kt
@@ -29,6 +29,11 @@
      * Use only for backup.
      */
     fun existsDb(packageName: String): Boolean
+
+    /**
+     * Returns the current size of the DB in bytes or null, if no DB exists.
+     */
+    fun getDbSize(packageName: String): Long?
     fun deleteDb(packageName: String, isRestore: Boolean = false): Boolean
 }
 
@@ -59,6 +64,11 @@
         return getDbFile(packageName).isFile
     }
 
+    override fun getDbSize(packageName: String): Long? {
+        val file = getDbFile(packageName)
+        return if (file.isFile) file.length() else null
+    }
+
     override fun deleteDb(packageName: String, isRestore: Boolean): Boolean {
         return getDbFile(packageName, isRestore).delete()
     }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt
index fbadc1a..58afe8d 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt
@@ -17,6 +17,8 @@
 import android.util.Log.INFO
 import androidx.annotation.WorkerThread
 import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
+import com.stevesoltys.seedvault.plugins.StoragePlugin
+import com.stevesoltys.seedvault.settings.SettingsManager
 
 private val TAG = PackageService::class.java.simpleName
 
@@ -29,12 +31,14 @@
 internal class PackageService(
     private val context: Context,
     private val backupManager: IBackupManager,
+    private val settingsManager: SettingsManager,
+    private val plugin: StoragePlugin,
 ) {
 
     private val packageManager: PackageManager = context.packageManager
     private val myUserId = UserHandle.myUserId()
 
-    val eligiblePackages: Array<String>
+    val eligiblePackages: List<String>
         @WorkerThread
         @Throws(RemoteException::class)
         get() {
@@ -45,13 +49,16 @@
             // log packages
             if (Log.isLoggable(TAG, INFO)) {
                 Log.i(TAG, "Got ${packages.size} packages:")
-                packages.chunked(LOG_MAX_PACKAGES).forEach {
-                    Log.i(TAG, it.toString())
-                }
+                logPackages(packages)
             }
 
-            val eligibleApps =
+            val eligibleApps = if (settingsManager.d2dBackupsEnabled()) {
+                // 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
             if (Log.isLoggable(TAG, INFO)) {
@@ -63,9 +70,13 @@
             val packageArray = eligibleApps.toMutableList()
             packageArray.add(MAGIC_PACKAGE_MANAGER)
 
-            return packageArray.toTypedArray()
+            return packageArray
         }
 
+    /**
+     * A list of packages that will not be backed up,
+     * because they are currently force-stopped for example.
+     */
     val notBackedUpPackages: List<PackageInfo>
         @WorkerThread
         get() {
@@ -94,32 +105,39 @@
     val userApps: List<PackageInfo>
         @WorkerThread
         get() = packageManager.getInstalledPackages(GET_INSTRUMENTATION).filter { packageInfo ->
-            packageInfo.isUserVisible(context) && packageInfo.allowsBackup()
+            packageInfo.isUserVisible(context) &&
+                packageInfo.allowsBackup()
         }
 
     /**
-     * A list of apps that does not allow backup.
+     * A list of apps that do not allow backup.
      */
     val userNotAllowedApps: List<PackageInfo>
         @WorkerThread
-        get() = packageManager.getInstalledPackages(0).filter { packageInfo ->
-            !packageInfo.allowsBackup() && !packageInfo.isSystemApp()
+        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 expectedAppTotals: ExpectedAppTotals
         @WorkerThread
         get() {
             var appsTotal = 0
-            var appsOptOut = 0
+            var appsNotIncluded = 0
             packageManager.getInstalledPackages(GET_INSTRUMENTATION).forEach { packageInfo ->
                 if (packageInfo.isUserVisible(context)) {
                     appsTotal++
                     if (packageInfo.doesNotGetBackedUp()) {
-                        appsOptOut++
+                        appsNotIncluded++
                     }
                 }
             }
-            return ExpectedAppTotals(appsTotal, appsOptOut)
+            return ExpectedAppTotals(appsTotal, appsNotIncluded)
         }
 
     fun getVersionName(packageName: String): String? = try {
@@ -128,12 +146,66 @@
         null
     }
 
+    fun shouldIncludeAppInBackup(packageName: String): Boolean {
+        // We do not use BackupManager.filterAppsEligibleForBackupForUser for D2D because it
+        // always makes its determinations based on OperationType.BACKUP, never based on
+        // OperationType.MIGRATION, and there are no alternative publicly-available APIs.
+        // We don't need to use it, here, either; during a backup or migration, the system
+        // will perform its own eligibility checks regardless. We merely need to filter out
+        // apps that we, or the user, want to exclude.
+
+        // Check that the app is not excluded by user preference
+        val enabled = settingsManager.isBackupEnabled(packageName)
+
+        // We need to explicitly exclude DocumentsProvider and Seedvault.
+        // Otherwise, they get killed while backing them up, terminating our backup.
+        val excludedPackages = setOf(
+            plugin.providerPackageName,
+            context.packageName
+        )
+
+        return enabled && !excludedPackages.contains(packageName)
+    }
+
     private fun logPackages(packages: List<String>) {
         packages.chunked(LOG_MAX_PACKAGES).forEach {
             Log.i(TAG, it.toString())
         }
     }
 
+    private fun PackageInfo.allowsBackup(): Boolean {
+        val appInfo = applicationInfo
+        if (packageName == MAGIC_PACKAGE_MANAGER || appInfo == null) return false
+
+        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
+        }
+    }
+
+    /**
+     * A flag indicating whether or not this package should _not_ be backed up.
+     *
+     * This happens when the app has opted-out of backup, or when it is stopped.
+     */
+    private fun PackageInfo.doesNotGetBackedUp(): Boolean {
+        if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
+        if (packageName == plugin.providerPackageName) return true
+        return !allowsBackup() || isStopped()
+    }
 }
 
 internal data class ExpectedAppTotals(
@@ -142,9 +214,11 @@
      */
     val appsTotal: Int,
     /**
-     * The number of non-system apps that has opted-out of backup.
+     * The number of non-system apps that do not get backed up.
+     * These are included here, because we'll at least back up their APKs,
+     * so at least the app itself does get restored.
      */
-    val appsOptOut: Int,
+    val appsNotGettingBackedUp: Int,
 )
 
 internal fun PackageInfo.isUserVisible(context: Context): Boolean {
@@ -153,13 +227,9 @@
 }
 
 internal fun PackageInfo.isSystemApp(): Boolean {
-    if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
-    return applicationInfo.flags and FLAG_SYSTEM != 0
-}
-
-internal fun PackageInfo.allowsBackup(): Boolean {
-    if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
-    return applicationInfo.flags and FLAG_ALLOW_BACKUP != 0
+    val appInfo = applicationInfo
+    if (packageName == MAGIC_PACKAGE_MANAGER || appInfo == null) return true
+    return appInfo.flags and FLAG_SYSTEM != 0
 }
 
 /**
@@ -167,24 +237,21 @@
  * We don't back up those APKs.
  */
 internal fun PackageInfo.isNotUpdatedSystemApp(): Boolean {
-    if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
-    val isSystemApp = applicationInfo.flags and FLAG_SYSTEM != 0
-    val isUpdatedSystemApp = applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
+    val appInfo = applicationInfo
+    if (packageName == MAGIC_PACKAGE_MANAGER || appInfo == null) return true
+    val isSystemApp = appInfo.flags and FLAG_SYSTEM != 0
+    val isUpdatedSystemApp = appInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
     return isSystemApp && !isUpdatedSystemApp
 }
 
-internal fun PackageInfo.doesNotGetBackedUp(): Boolean {
-    if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return true
-    return applicationInfo.flags and FLAG_ALLOW_BACKUP == 0 || // does not allow backup
-        applicationInfo.flags and FLAG_STOPPED != 0 // is stopped
-}
-
 internal fun PackageInfo.isStopped(): Boolean {
-    if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
-    return applicationInfo.flags and FLAG_STOPPED != 0
+    val appInfo = applicationInfo
+    if (packageName == MAGIC_PACKAGE_MANAGER || appInfo == null) return false
+    return appInfo.flags and FLAG_STOPPED != 0
 }
 
 internal fun PackageInfo.isTestOnly(): Boolean {
-    if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
-    return applicationInfo.flags and FLAG_TEST_ONLY != 0
+    val appInfo = applicationInfo
+    if (packageName == MAGIC_PACKAGE_MANAGER || appInfo == null) return false
+    return appInfo.flags and FLAG_TEST_ONLY != 0
 }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
index 23d8b6a..6843125 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinator.kt
@@ -22,10 +22,19 @@
 import com.stevesoltys.seedvault.metadata.MetadataReader
 import com.stevesoltys.seedvault.plugins.StoragePlugin
 import com.stevesoltys.seedvault.settings.SettingsManager
-import com.stevesoltys.seedvault.transport.TRANSPORT_FLAGS
+import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS
+import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS
 import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
 import java.io.IOException
 
+/**
+ * Device name used in AOSP to indicate that a restore set is part of a device-to-device migration.
+ * See getBackupEligibilityRules in frameworks/base/services/backup/java/com/android/server/
+ * backup/restore/ActiveRestoreSession.java. AOSP currently relies on this constant, and it is not
+ * publicly exposed. Framework code indicates they intend to use a flag, instead, in the future.
+ */
+internal const val D2D_DEVICE_NAME = "D2D"
+
 private data class RestoreCoordinatorState(
     val token: Long,
     val packages: Iterator<PackageInfo>,
@@ -92,7 +101,20 @@
      **/
     suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
         return getAvailableMetadata()?.map { (_, metadata) ->
-            RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token, TRANSPORT_FLAGS)
+
+            val transportFlags = if (metadata.d2dBackup) {
+                D2D_TRANSPORT_FLAGS
+            } else {
+                DEFAULT_TRANSPORT_FLAGS
+            }
+
+            val deviceName = if (metadata.d2dBackup) {
+                D2D_DEVICE_NAME
+            } else {
+                metadata.deviceName
+            }
+
+            RestoreSet(metadata.deviceName, deviceName, metadata.token, transportFlags)
         }?.toTypedArray()
     }
 
@@ -114,6 +136,10 @@
      */
     fun beforeStartRestore(backupMetadata: BackupMetadata) {
         this.backupMetadata = backupMetadata
+
+        if (backupMetadata.d2dBackup) {
+            settingsManager.setD2dBackupsEnabled(true)
+        }
     }
 
     /**
@@ -219,6 +245,7 @@
                         TYPE_KEY_VALUE
                     } else throw IOException("No data found for $packageName. Skipping.")
                 }
+
                 BackupType.FULL -> {
                     val name = crypto.getNameForPackage(state.backupMetadata.salt, packageName)
                     if (plugin.hasData(state.token, name)) {
@@ -228,6 +255,7 @@
                         TYPE_FULL_STREAM
                     } else throw IOException("No data found for $packageName. Skipping...")
                 }
+
                 null -> {
                     Log.i(TAG, "No backup type found for $packageName. Skipping...")
                     state.backupMetadata.packageMetadataMap[packageName]?.backupType?.let { s ->
@@ -261,12 +289,14 @@
                     state.currentPackage = packageName
                     TYPE_KEY_VALUE
                 }
+
                 full.hasDataForPackage(state.token, packageInfo) -> {
                     Log.i(TAG, "Found full backup data for $packageName.")
                     full.initializeState(0x00, state.token, "", packageInfo)
                     state.currentPackage = packageName
                     TYPE_FULL_STREAM
                 }
+
                 else -> {
                     Log.i(TAG, "No data found for $packageName. Skipping.")
                     return nextRestorePackage()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt
index 37686fc..8a3cd45 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/AppViewHolder.kt
@@ -8,9 +8,9 @@
 import android.view.View.VISIBLE
 import android.widget.ImageView
 import android.widget.ProgressBar
-import android.widget.Switch
 import android.widget.TextView
 import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.switchmaterial.SwitchMaterial
 import com.stevesoltys.seedvault.R
 import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
 import com.stevesoltys.seedvault.ui.AppBackupState.IN_PROGRESS
@@ -22,12 +22,12 @@
     protected val pm: PackageManager = context.packageManager
 
     protected val clickableBackground = v.background!!
-    protected val appIcon: ImageView = v.findViewById(R.id.appIcon)
-    protected val appName: TextView = v.findViewById(R.id.appName)
-    protected val appInfo: TextView = v.findViewById(R.id.appInfo)
-    protected val appStatus: ImageView = v.findViewById(R.id.appStatus)
-    protected val progressBar: ProgressBar = v.findViewById(R.id.progressBar)
-    protected val switchView: Switch = v.findViewById(R.id.switchView)
+    protected val appIcon: ImageView = v.requireViewById(R.id.appIcon)
+    protected val appName: TextView = v.requireViewById(R.id.appName)
+    protected val appInfo: TextView = v.requireViewById(R.id.appInfo)
+    protected val appStatus: ImageView = v.requireViewById(R.id.appStatus)
+    protected val progressBar: ProgressBar = v.requireViewById(R.id.progressBar)
+    protected val switchView: SwitchMaterial = v.requireViewById(R.id.switchView)
 
     init {
         // don't use clickable background by default
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/LiveEvent.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/LiveEvent.kt
index 7dac0f9..46720ac 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/LiveEvent.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/LiveEvent.kt
@@ -25,11 +25,9 @@
 
     internal class LiveEventObserver<T>(private val handler: LiveEventHandler<in T>) :
         Observer<ConsumableEvent<T>> {
-        override fun onChanged(consumableEvent: ConsumableEvent<T>?) {
-            if (consumableEvent != null) {
-                val content = consumableEvent.contentIfNotConsumed
-                if (content != null) handler.onEvent(content)
-            }
+        override fun onChanged(value: ConsumableEvent<T>) {
+            val content = value.contentIfNotConsumed
+            if (content != null) handler.onEvent(content)
         }
     }
 
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
index 35b412f..3ba30da 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/BackupNotificationManager.kt
@@ -12,6 +12,7 @@
 import android.content.Context
 import android.content.Intent
 import android.content.pm.PackageManager.NameNotFoundException
+import android.text.format.Formatter
 import android.util.Log
 import androidx.core.app.NotificationCompat.Action
 import androidx.core.app.NotificationCompat.Builder
@@ -26,15 +27,18 @@
 import com.stevesoltys.seedvault.settings.ACTION_APP_STATUS_LIST
 import com.stevesoltys.seedvault.settings.SettingsActivity
 import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals
+import kotlin.math.min
 
 private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
+private const val CHANNEL_ID_SUCCESS = "NotificationBackupSuccess"
 private const val CHANNEL_ID_ERROR = "NotificationError"
 private const val CHANNEL_ID_RESTORE_ERROR = "NotificationRestoreError"
 private const val NOTIFICATION_ID_OBSERVER = 1
-private const val NOTIFICATION_ID_ERROR = 2
-private const val NOTIFICATION_ID_RESTORE_ERROR = 3
-private const val NOTIFICATION_ID_BACKGROUND = 4
-private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 5
+private const val NOTIFICATION_ID_SUCCESS = 2
+private const val NOTIFICATION_ID_ERROR = 3
+private const val NOTIFICATION_ID_RESTORE_ERROR = 4
+private const val NOTIFICATION_ID_BACKGROUND = 5
+private const val NOTIFICATION_ID_NO_MAIN_KEY_ERROR = 6
 
 private val TAG = BackupNotificationManager::class.java.simpleName
 
@@ -42,6 +46,7 @@
 
     private val nm = context.getSystemService(NotificationManager::class.java)!!.apply {
         createNotificationChannel(getObserverChannel())
+        createNotificationChannel(getSuccessChannel())
         createNotificationChannel(getErrorChannel())
         createNotificationChannel(getRestoreErrorChannel())
     }
@@ -49,6 +54,11 @@
     private var expectedOptOutApps: Int? = null
     private var expectedAppTotals: ExpectedAppTotals? = null
 
+    /**
+     * Used as a (temporary) hack to fix progress reporting when fake d2d is enabled.
+     */
+    private var optOutAppsDone = false
+
     private fun getObserverChannel(): NotificationChannel {
         val title = context.getString(R.string.notification_channel_title)
         return NotificationChannel(CHANNEL_ID_OBSERVER, title, IMPORTANCE_LOW).apply {
@@ -56,6 +66,13 @@
         }
     }
 
+    private fun getSuccessChannel(): NotificationChannel {
+        val title = context.getString(R.string.notification_success_channel_title)
+        return NotificationChannel(CHANNEL_ID_SUCCESS, title, IMPORTANCE_LOW).apply {
+            enableVibration(false)
+        }
+    }
+
     private fun getErrorChannel(): NotificationChannel {
         val title = context.getString(R.string.notification_error_channel_title)
         return NotificationChannel(CHANNEL_ID_ERROR, title, IMPORTANCE_DEFAULT)
@@ -76,23 +93,34 @@
         updateBackupNotification(
             infoText = "", // This passes quickly, no need to show something here
             transferred = 0,
-            expected = expectedPackages
+            expected = appTotals.appsTotal
         )
         expectedApps = expectedPackages
-        expectedOptOutApps = appTotals.appsOptOut
+        expectedOptOutApps = appTotals.appsNotGettingBackedUp
         expectedAppTotals = appTotals
+        optOutAppsDone = false
+        Log.i(TAG, "onBackupStarted $expectedApps + $expectedOptOutApps = ${appTotals.appsTotal}")
     }
 
     /**
      * This should get called before [onBackupUpdate].
+     * In case of d2d backups, this actually gets called some time after
+     * some apps were already backed up, so [onBackupUpdate] was called several times.
      */
     fun onOptOutAppBackup(packageName: String, transferred: Int, expected: Int) {
-        val text = "Opt-out APK for $packageName"
+        if (optOutAppsDone) return
+
+        val text = "APK for $packageName"
         if (expectedApps == null) {
             updateBackgroundBackupNotification(text)
         } else {
             updateBackupNotification(text, transferred, expected + (expectedApps ?: 0))
+            if (expectedOptOutApps != null && expectedOptOutApps != expected) {
+                Log.w(TAG, "Number of packages not getting backed up mismatch: " +
+                    "$expectedOptOutApps != $expected")
+            }
             expectedOptOutApps = expected
+            if (transferred == expected) optOutAppsDone = true
         }
     }
 
@@ -105,7 +133,7 @@
         val addend = expectedOptOutApps ?: 0
         updateBackupNotification(
             infoText = app,
-            transferred = transferred + addend,
+            transferred = min(transferred + addend, expected + addend),
             expected = expected + addend
         )
     }
@@ -156,28 +184,33 @@
         //
         // This won't bring back the expected finish notification in this case,
         // but at least we don't leave stuck notifications laying around.
-        nm.activeNotifications.forEach { notification ->
-            // only consider ongoing notifications in our ID space (storage backup uses > 1000)
-            if (notification.isOngoing && notification.id < 1000) {
-                Log.w(TAG, "Needed to clean up notification with ID ${notification.id}")
-                nm.cancel(notification.id)
-            }
-        }
+        // FIXME the service gets destroyed for each chunk when requesting backup in chunks
+        //  This leads to the cancellation of an ongoing backup notification.
+        //  So for now, we'll remove automatic notification clean-up
+        //  and find out if it is still necessary. If not, this comment can be removed.
+        // nm.activeNotifications.forEach { notification ->
+        //     // only consider ongoing notifications in our ID space (storage backup uses > 1000)
+        //     if (notification.isOngoing && notification.id < 1000) {
+        //         Log.w(TAG, "Needed to clean up notification with ID ${notification.id}")
+        //         nm.cancel(notification.id)
+        //     }
+        // }
     }
 
-    fun onBackupFinished(success: Boolean, numBackedUp: Int?) {
+    fun onBackupFinished(success: Boolean, numBackedUp: Int?, size: Long) {
         val titleRes =
             if (success) R.string.notification_success_title else R.string.notification_failed_title
         val total = expectedAppTotals?.appsTotal
         val contentText = if (numBackedUp == null || total == null) null else {
-            context.getString(R.string.notification_success_text, numBackedUp, total)
+            val sizeStr = Formatter.formatShortFileSize(context, size)
+            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 {
             if (success) action = ACTION_APP_STATUS_LIST
         }
         val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAG_IMMUTABLE)
-        val notification = Builder(context, CHANNEL_ID_OBSERVER).apply {
+        val notification = Builder(context, CHANNEL_ID_SUCCESS).apply {
             setSmallIcon(iconRes)
             setContentTitle(context.getString(titleRes))
             setContentText(contentText)
@@ -189,7 +222,8 @@
             setProgress(0, 0, false)
             priority = PRIORITY_LOW
         }.build()
-        nm.notify(NOTIFICATION_ID_OBSERVER, notification)
+        nm.cancel(NOTIFICATION_ID_OBSERVER)
+        nm.notify(NOTIFICATION_ID_SUCCESS, notification)
         // reset number of expected apps
         expectedOptOutApps = null
         expectedApps = null
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt
index d35971f..4723521 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt
@@ -10,6 +10,7 @@
 import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
 import com.stevesoltys.seedvault.R
 import com.stevesoltys.seedvault.metadata.MetadataManager
+import com.stevesoltys.seedvault.transport.backup.BackupRequester
 import com.stevesoltys.seedvault.transport.backup.ExpectedAppTotals
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
@@ -18,7 +19,8 @@
 
 internal class NotificationBackupObserver(
     private val context: Context,
-    private val expectedPackages: Int,
+    private val backupRequester: BackupRequester,
+    private val requestedPackages: Int,
     appTotals: ExpectedAppTotals,
 ) : IBackupObserver.Stub(), KoinComponent {
 
@@ -30,7 +32,7 @@
     init {
         // Inform the notification manager that a backup has started
         // and inform about the expected numbers, so it can compute a total.
-        nm.onBackupStarted(expectedPackages, appTotals)
+        nm.onBackupStarted(requestedPackages, appTotals)
     }
 
     /**
@@ -73,24 +75,31 @@
      *   as a whole failed.
      */
     override fun backupFinished(status: Int) {
-        if (isLoggable(TAG, INFO)) {
-            Log.i(TAG, "Backup finished $numPackages/$expectedPackages. Status: $status")
+        if (backupRequester.requestNext()) {
+            if (isLoggable(TAG, INFO)) {
+                Log.i(TAG, "Backup finished $numPackages/$requestedPackages. Status: $status")
+            }
+            val success = status == 0
+            val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null
+            val size = if (success) metadataManager.getPackagesBackupSize() else 0L
+            nm.onBackupFinished(success, numBackedUp, size)
         }
-        val success = status == 0
-        val numBackedUp = if (success) metadataManager.getPackagesNumBackedUp() else null
-        nm.onBackupFinished(success, numBackedUp)
     }
 
     private fun showProgressNotification(packageName: String?) {
         if (packageName == null || currentPackage == packageName) return
 
-        if (isLoggable(TAG, INFO)) {
-            "Showing progress notification for $currentPackage $numPackages/$expectedPackages".let {
-                Log.i(TAG, it)
-            }
-        }
+        if (isLoggable(TAG, INFO)) Log.i(
+            TAG, "Showing progress notification for " +
+                "$currentPackage $numPackages/$requestedPackages"
+        )
         currentPackage = packageName
-        val app = getAppName(packageName)
+        val appName = getAppName(packageName)
+        val app = if (appName != packageName) {
+            "${getAppName(packageName)} ($packageName)"
+        } else {
+            packageName
+        }
         numPackages += 1
         nm.onBackupUpdate(app, numPackages)
     }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeAdapter.kt
index 1003300..5d35a5c 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeAdapter.kt
@@ -27,8 +27,8 @@
 
 class RecoveryCodeViewHolder(v: View) : RecyclerView.ViewHolder(v) {
 
-    private val num = v.findViewById<TextView>(R.id.num)
-    private val word = v.findViewById<TextView>(R.id.word)
+    private val num = v.requireViewById<TextView>(R.id.num)
+    private val word = v.requireViewById<TextView>(R.id.word)
 
     internal fun bind(number: Int, item: CharArray) {
         num.text = number.toString()
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt
index 76ee2d6..4853aaa 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeInputFragment.kt
@@ -71,24 +71,24 @@
     ): View {
         val v: View = inflater.inflate(R.layout.fragment_recovery_code_input, container, false)
 
-        if (!isDebugBuild()) getActivity()?.window?.addFlags(FLAG_SECURE)
+        if (!isDebugBuild()) activity?.window?.addFlags(FLAG_SECURE)
 
-        introText = v.findViewById(R.id.introText)
-        doneButton = v.findViewById(R.id.doneButton)
-        newCodeButton = v.findViewById(R.id.newCodeButton)
-        wordLayout1 = v.findViewById(R.id.wordLayout1)
-        wordLayout2 = v.findViewById(R.id.wordLayout2)
-        wordLayout3 = v.findViewById(R.id.wordLayout3)
-        wordLayout4 = v.findViewById(R.id.wordLayout4)
-        wordLayout5 = v.findViewById(R.id.wordLayout5)
-        wordLayout6 = v.findViewById(R.id.wordLayout6)
-        wordLayout7 = v.findViewById(R.id.wordLayout7)
-        wordLayout8 = v.findViewById(R.id.wordLayout8)
-        wordLayout9 = v.findViewById(R.id.wordLayout9)
-        wordLayout10 = v.findViewById(R.id.wordLayout10)
-        wordLayout11 = v.findViewById(R.id.wordLayout11)
-        wordLayout12 = v.findViewById(R.id.wordLayout12)
-        wordList = v.findViewById(R.id.wordList)
+        introText = v.requireViewById(R.id.introText)
+        doneButton = v.requireViewById(R.id.doneButton)
+        newCodeButton = v.requireViewById(R.id.newCodeButton)
+        wordLayout1 = v.requireViewById(R.id.wordLayout1)
+        wordLayout2 = v.requireViewById(R.id.wordLayout2)
+        wordLayout3 = v.requireViewById(R.id.wordLayout3)
+        wordLayout4 = v.requireViewById(R.id.wordLayout4)
+        wordLayout5 = v.requireViewById(R.id.wordLayout5)
+        wordLayout6 = v.requireViewById(R.id.wordLayout6)
+        wordLayout7 = v.requireViewById(R.id.wordLayout7)
+        wordLayout8 = v.requireViewById(R.id.wordLayout8)
+        wordLayout9 = v.requireViewById(R.id.wordLayout9)
+        wordLayout10 = v.requireViewById(R.id.wordLayout10)
+        wordLayout11 = v.requireViewById(R.id.wordLayout11)
+        wordLayout12 = v.requireViewById(R.id.wordLayout12)
+        wordList = v.requireViewById(R.id.wordList)
 
         arguments?.getBoolean(ARG_FOR_NEW_CODE, true)?.let {
             forStoringNewCode = it
@@ -148,7 +148,7 @@
         }
         if (forStoringNewCode) {
             val keyguardManager = requireContext().getSystemService(KeyguardManager::class.java)
-            if (keyguardManager.isDeviceSecure) {
+            if (keyguardManager?.isDeviceSecure == true) {
                 // if we have a lock-screen secret, we can ask for it before storing the code
                 storeNewCodeAfterAuth(input)
             } else {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeOutputFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeOutputFragment.kt
index 012ce09..ca585e3 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeOutputFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/recoverycode/RecoveryCodeOutputFragment.kt
@@ -28,10 +28,10 @@
     ): View {
         val v: View = inflater.inflate(R.layout.fragment_recovery_code_output, container, false)
 
-        if (!isDebugBuild()) getActivity()?.window?.addFlags(FLAG_SECURE)
+        if (!isDebugBuild()) activity?.window?.addFlags(FLAG_SECURE)
 
-        wordList = v.findViewById(R.id.wordList)
-        confirmCodeButton = v.findViewById(R.id.confirmCodeButton)
+        wordList = v.requireViewById(R.id.wordList)
+        confirmCodeButton = v.requireViewById(R.id.confirmCodeButton)
 
         return v
     }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt
index fe45f4c..b1df24e 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageCheckFragment.kt
@@ -41,10 +41,10 @@
     ): View {
         val v: View = inflater.inflate(R.layout.fragment_storage_check, container, false)
 
-        titleView = v.findViewById(R.id.titleView)
-        progressBar = v.findViewById(R.id.progressBar)
-        errorView = v.findViewById(R.id.errorView)
-        backButton = v.findViewById(R.id.backButton)
+        titleView = v.requireViewById(R.id.titleView)
+        progressBar = v.requireViewById(R.id.progressBar)
+        errorView = v.requireViewById(R.id.errorView)
+        backButton = v.requireViewById(R.id.backButton)
 
         return v
     }
@@ -56,7 +56,7 @@
 
         val errorMsg = requireArguments().getString(ERROR_MSG)
         if (errorMsg != null) {
-            view.findViewById<View>(R.id.patienceView).visibility = GONE
+            view.requireViewById<View>(R.id.patienceView).visibility = GONE
             progressBar.visibility = INVISIBLE
             errorView.text = errorMsg
             errorView.visibility = VISIBLE
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionAdapter.kt
index ff4c448..ab2a906 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionAdapter.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionAdapter.kt
@@ -45,9 +45,9 @@
 
     internal inner class StorageOptionViewHolder(private val v: View) : ViewHolder(v) {
 
-        private val iconView = v.findViewById<ImageView>(R.id.iconView)
-        private val titleView = v.findViewById<TextView>(R.id.titleView)
-        private val summaryView = v.findViewById<TextView>(R.id.summaryView)
+        private val iconView = v.requireViewById<ImageView>(R.id.iconView)
+        private val titleView = v.requireViewById<TextView>(R.id.titleView)
+        private val summaryView = v.requireViewById<TextView>(R.id.summaryView)
 
         internal fun bind(item: StorageOption) {
             if (item.enabled) {
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionsFragment.kt
index fc84248..12c6526 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionsFragment.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageOptionsFragment.kt
@@ -57,12 +57,12 @@
     ): View {
         val v: View = inflater.inflate(R.layout.fragment_storage_root, container, false)
 
-        titleView = v.findViewById(R.id.titleView)
-        warningIcon = v.findViewById(R.id.warningIcon)
-        warningText = v.findViewById(R.id.warningText)
-        listView = v.findViewById(R.id.listView)
-        progressBar = v.findViewById(R.id.progressBar)
-        skipView = v.findViewById(R.id.skipView)
+        titleView = v.requireViewById(R.id.titleView)
+        warningIcon = v.requireViewById(R.id.warningIcon)
+        warningText = v.requireViewById(R.id.warningText)
+        listView = v.requireViewById(R.id.listView)
+        progressBar = v.requireViewById(R.id.progressBar)
+        skipView = v.requireViewById(R.id.skipView)
 
         return v
     }
diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt
index 9db71b6..5a48e2e 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt
@@ -20,7 +20,7 @@
 import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
 import android.util.Log
 import com.stevesoltys.seedvault.R
-import com.stevesoltys.seedvault.getSystemContext
+import com.stevesoltys.seedvault.getStorageContext
 import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
 
 internal object StorageRootResolver {
@@ -39,7 +39,7 @@
                 }
             }
             // add special system user roots for USB devices
-            val c = context.getSystemContext {
+            val c = context.getStorageContext {
                 authority == AUTHORITY_STORAGE && UserHandle.myUserId() != UserHandle.USER_SYSTEM
             }
             // only proceed if we really got a different [Context], e.g. had permission for it
diff --git a/app/src/main/res/layout/list_item_app_status.xml b/app/src/main/res/layout/list_item_app_status.xml
index 5d630c7..b556a19 100644
--- a/app/src/main/res/layout/list_item_app_status.xml
+++ b/app/src/main/res/layout/list_item_app_status.xml
@@ -68,7 +68,7 @@
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintTop_toTopOf="parent" />
 
-    <Switch
+    <com.google.android.material.switchmaterial.SwitchMaterial
         android:id="@+id/switchView"
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index fd2b634..a0564aa 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -52,7 +52,7 @@
     <string name="recovery_code_input_hint_12">Paraula 12</string>
     <string name="recovery_code_error_checksum_word">El codi no és vàlid. Comprova totes les paraules així com la seva posició i torna-ho a provar!</string>
     <string name="recovery_code_verification_ok_title">Codi de recuperació verificat</string>
-    <string name="notification_success_text">%1$d de %2$d aplicacions amb còpia de seguretat. Fes un toc per saber-ne més.</string>
+    <string name="notification_success_text">%1$d de %2$d aplicacions amb còpia de seguretat (%3$s). Fes un toc per saber-ne més.</string>
     <string name="recovery_code_verification_new_dialog_message">Generar un codi nou farà que les teves còpies de seguretat existents siguin inaccessibles. Intentarem suprimir-los si és possible.
 \n
 \nSegur que vols fer-ho\?</string>
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 89fdf1f..9913bb4 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -60,7 +60,7 @@
     <string name="notification_error_title">Chyba zálohování</string>
     <string name="notification_error_channel_title">Oznámení o chybě</string>
     <string name="notification_failed_title">Zálohování selhalo</string>
-    <string name="notification_success_text">%1$d z %2$d aplikací zálohováno. Klepnutím zobrazíte další informace.</string>
+    <string name="notification_success_text">%1$d z %2$d aplikací zálohováno (%3$s). Klepnutím zobrazíte další informace.</string>
     <string name="notification_success_title">Zálohování dokončeno</string>
     <string name="notification_backup_already_running">Zálohování již probíhá</string>
     <string name="notification_title">Probíhá zálohování</string>
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index d2861fd..6702f25 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -103,7 +103,7 @@
     <string name="restore_restore_set_times">Letzte Sicherung %1$s · Erste %2$s.</string>
     <string name="restore_choose_restore_set">Wähle eine Sicherung aus, um sie wiederherzustellen</string>
     <string name="notification_restore_error_action">App deinstallieren</string>
-    <string name="notification_success_text">%1$d von %2$d Apps gesichert. Tippe, um mehr zu erfahren.</string>
+    <string name="notification_success_text">%1$d von %2$d Apps gesichert (%3$s). Tippe, um mehr zu erfahren.</string>
     <string name="notification_backup_already_running">Sicherung wird bereits durchgeführt</string>
     <string name="storage_fake_nextcloud_summary_unavailable">Konto nicht verfügbar. Richte ein Konto ein (oder deaktivieren Passcode).</string>
     <string name="current_destination_string">Sicherungsstatus und Einstellungen</string>
@@ -203,4 +203,5 @@
     <string name="about_contributor_headline">Mitwirkende</string>
     <string name="about_contributor_content">Eine <a href="https://github.com/seedvault-app/seedvault/graphs/contributors">Liste der Mitwirkenden</a> findest du auf GitHub.</string>
     <string name="about_contributing_organizations_title">Mitwirkende Organisationen</string>
+    <string name="storage_user_selected_location_title">Benutzerdefinierter Speicherort</string>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index a550af0..df89abb 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -71,7 +71,7 @@
     <string name="notification_error_title">Σφάλμα δημιουργίας αντιγράφων ασφαλείας</string>
     <string name="notification_error_channel_title">Ειδοποίηση σφάλματος</string>
     <string name="notification_failed_title">Η δημιουργία αντιγράφων ασφαλείας απέτυχε</string>
-    <string name="notification_success_text">Δημιουργήθηκαν αντίγραφα ασφαλείας για %1$d από %2$d εφαρμογές. Πατήστε για να μάθετε περισσότερα.</string>
+    <string name="notification_success_text">Δημιουργήθηκαν αντίγραφα ασφαλείας για %1$d από %2$d εφαρμογές (%3$s). Πατήστε για να μάθετε περισσότερα.</string>
     <string name="notification_success_title">Η δημιουργία αντιγράφων ασφαλείας ολοκληρώθηκε</string>
     <string name="notification_backup_already_running">Η δημιουργία αντιγράφων ασφαλείας βρίσκεται ήδη σε εξέλιξη</string>
     <string name="notification_title">Εκτελείται δημιουργία αντιγράφων ασφαλείας</string>
@@ -203,4 +203,5 @@
     <string name="about_contributing_organizations_title">Συνεισφέροντες Οργανισμοί</string>
     <string name="about_contributing_organizations_content"><a href="https://www.calyxinstitute.org">Ινστιτούτο Calyx</a> για χρήση στο <a href="https://calyxos.org">CalyxOS</a>
 \n<a href="https://nlnet.nl/project/Seedvault/">NGI0 PET χρηματοδότηση από την NLnet</a></string>
+    <string name="storage_user_selected_location_title">Τοποθεσία επιλεγμένη από το χρήστη</string>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/values-en-rAU/strings.xml b/app/src/main/res/values-en-rAU/strings.xml
index 0fcac19..2a31358 100644
--- a/app/src/main/res/values-en-rAU/strings.xml
+++ b/app/src/main/res/values-en-rAU/strings.xml
@@ -160,7 +160,7 @@
     <string name="notification_backup_already_running">Backup already in progress</string>
     <string name="notification_backup_disabled">Backup not enabled</string>
     <string name="notification_success_title">Backup finished</string>
-    <string name="notification_success_text">%1$d of %2$d apps backed up. Tap to learn more.</string>
+    <string name="notification_success_text">%1$d of %2$d apps backed up (%3$s). Tap to learn more.</string>
     <string name="notification_error_channel_title">Error notification</string>
     <string name="notification_error_action">Fix</string>
     <string name="notification_restore_error_channel_title">Auto restore flash drive error</string>
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 0330d11..09f341e 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -101,7 +101,7 @@
     <string name="settings_backup">Copia de seguridad de mis apps</string>
     <string name="restore_backup_button">Restaurar la copia de seguridad</string>
     <string name="backup">Copia de seguridad</string>
-    <string name="notification_success_text">%1$d de %2$d aplicaciones con copia de seguridad. Pulse para obtener más información.</string>
+    <string name="notification_success_text">%1$d de %2$d aplicaciones con copia de seguridad (%3$s). Pulse para obtener más información.</string>
     <string name="notification_backup_already_running">Copia de seguridad en curso</string>
     <string name="restore_app_not_yet_backed_up">Todavía no se ha hecho una copia de seguridad</string>
     <string name="restore_app_was_stopped">No se hizo una copia de seguridad porque no se había utilizado recientemente</string>
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index 2d4dc3b..173877e 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -59,7 +59,7 @@
     <string name="notification_error_title">Varmuuskopiointivirhe</string>
     <string name="notification_error_channel_title">Virheilmoitus</string>
     <string name="notification_failed_title">Varmuuskopiointi epäonnistui</string>
-    <string name="notification_success_text">%1$d/%2$d sovellusta varmuuskopioitu. Napauta saadaksesi lisätietoja.</string>
+    <string name="notification_success_text">%1$d/%2$d sovellusta varmuuskopioitu (%3$s). Napauta saadaksesi lisätietoja.</string>
     <string name="notification_success_title">Varmuuskopiointi valmis</string>
     <string name="notification_backup_already_running">Varmuuskopiointi on jo käynnissä</string>
     <string name="notification_title">Varmuuskopiointi on käynnissä</string>
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 2cc2c98..3e46969 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -101,7 +101,7 @@
     <string name="settings_backup_apk_summary">Sauvegardez les applications elles-mêmes. Sinon, seules les données des applications sont sauvegardées.</string>
     <string name="restore_app_was_stopped">N\'a pas été sauvegardé car n\'a pas été utilisé récemment</string>
     <string name="restore_app_not_yet_backed_up">Pas encore sauvegardé</string>
-    <string name="notification_success_text">%1$d des applications %2$d sauvegardées. Appuyez pour en savoir plus.</string>
+    <string name="notification_success_text">%1$d des applications %2$d sauvegardées (%3$s). Appuyez pour en savoir plus.</string>
     <string name="notification_backup_already_running">Sauvegarde déjà en cours</string>
     <string name="storage_fake_nextcloud_summary_unavailable">Compte non disponible. Configurez-en un (ou désactivez le mot de passe).</string>
     <string name="current_destination_string">Statut et paramètres de sauvegarde</string>
@@ -188,4 +188,8 @@
     <string name="restore_app_status_failed">Échec de la restauration</string>
     <string name="restore_app_status_warning">Alerte de restauration</string>
     <string name="settings_backup_dialog_disable">Eteindre quand même</string>
+    <string name="settings_expert_logcat_title">Sauvegarde des logs</string>
+    <string name="settings_backup_dialog_message">A la prochaine activation ,
+\nLe processus de sauvegarde peux durer plus longtemps et utilisera du stockage additionnel.</string>
+    <string name="settings_backup_dialog_title">Désactiver les sauvegardes de l\'App\?</string>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml
index c38e935..aae2d5a 100644
--- a/app/src/main/res/values-hr/strings.xml
+++ b/app/src/main/res/values-hr/strings.xml
@@ -1,12 +1,12 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
-    <string name="notification_success_text">Sigurnosne kopije spremljene za %1$d od %2$d aplikacija. Dodirni i saznaj više.</string>
+    <string name="notification_success_text">Sigurnosne kopije spremljene za %1$d od %2$d aplikacija (%3$s). Dodirni i saznaj više.</string>
     <string name="notification_backup_already_running">Spremanje sigurnosne kopije je u tijeku</string>
     <string name="about_summary">Aplikacija za izradu sigurnosnih kopija koja koristi Androidovo interno sučelje.</string>
     <string name="about_title">Informacije</string>
     <string name="storage_internal_warning_use_anyway">Svejedno koristi</string>
     <string name="storage_internal_warning_choose_other">Odaberi drugo</string>
-    <string name="storage_internal_warning_message">Za sigurnosnu kopiju odabrano je interno spremište. Neće biti dostupno ako se telefon izgubi ili pokvari.</string>
+    <string name="storage_internal_warning_message">Za sigurnosnu kopiju odabrano je interno spremište (%3$s). Neće biti dostupno ako se telefon izgubi ili pokvari.</string>
     <string name="storage_internal_warning_title">Upozorenje</string>
     <string name="restore_finished_button">Završi</string>
     <string name="restore_finished_error">Došlo je do greške prilikom vraćanja sigurnosne kopije.</string>
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index bb42fc4..7b02de2 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -17,7 +17,7 @@
     <string name="settings_auto_restore_summary_usb">Catatan: %1$s Anda harus dicolokkan agar ini berfungsi.</string>
     <string name="settings_auto_restore_summary">Saat menginstal ulang aplikasi, pulihkan pengaturan dan data yang dicadangkan.</string>
     <string name="settings_auto_restore_title">Pemulihan otomatis</string>
-    <string name="settings_info">Semua cadangan terenkripsi di ponsel Anda. Untuk memulihkannya, anda memerlukan kode dari 12-word recovery.</string>
+    <string name="settings_info">Semua cadangan dienkripsi di ponsel Anda. Untuk memulihkan cadangan, Anda memerlukan kode pemulihan 12 kata.</string>
     <string name="settings_backup_last_backup_never">Tidak pernah</string>
     <string name="settings_backup_location_internal">Penyimpanan internal</string>
     <string name="settings_backup_location_none">Tidak ada</string>
@@ -87,4 +87,7 @@
     <string name="backup_settings">Pengaturan perangkat</string>
     <string name="backup_contacts">Kontak lokal</string>
     <string name="backup_section_system">Aplikasi sistem</string>
+    <string name="settings_backup_dialog_disable">Tetap nonaktifkan</string>
+    <string name="settings_backup_dialog_message">Ketika Anda mengaktifkan pencadangan aplikasi lagi, proses pencadangan mungkin memakan waktu yang lebih lama dari biasanya, dan akan menggunakan penyimpanan ekstra.</string>
+    <string name="settings_backup_dialog_title">Nonaktifkan pencadangan aplikasi\?</string>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index fb9f4a3..17f12e0 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -119,7 +119,7 @@
     <string name="notification_error_text">Il backup del dispositivo non e\' partito.</string>
     <string name="notification_error_title">Errore di backup</string>
     <string name="notification_error_channel_title">Notifica d\'errore</string>
-    <string name="notification_success_text">%1$d di %2$d app gia\' in backup. Tocca per ulteriori informazioni.</string>
+    <string name="notification_success_text">%1$d di %2$d app gia\' in backup (%3$s). Tocca per ulteriori informazioni.</string>
     <string name="notification_backup_already_running">Backup gia\' in corso</string>
     <string name="recovery_code_confirm_intro">Inserisci il tuo codice di recupero composto da 12 parole per controllare ora che il tutto funzionera\' correttamente quando sara\' necessario.</string>
     <string name="recovery_code_input_intro">Inserisi il tuo codice di recupero composto da 12 parole che ti sei annotato quando hai inizializzato i backup.</string>
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index 7c02a26..8b939a8 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -68,7 +68,6 @@
     <string name="notification_error_title">שגיאת גיבוי</string>
     <string name="notification_error_channel_title">התראת שגיאה</string>
     <string name="notification_failed_title">הגיבוי נכשל</string>
-    <string name="notification_success_text">%1$d מתוך %2$d יישומונים גובו. הקש למידע נוסף.</string>
     <string name="notification_success_title">הגיבוי הסתיים</string>
     <string name="notification_backup_already_running">הגיבוי כבר מתבצע</string>
     <string name="about_summary">יישומון גיבוי שמשתמש ב־API הפנימי לגיבוי של Android. תוכנה חופשית שכפופה לרישיון Apache 2.
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 69f57a0..7c73874 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -116,7 +116,7 @@
     <string name="notification_error_action">修正する</string>
     <string name="notification_error_text">デバイスのバックアップを実行できませんでした。</string>
     <string name="notification_error_channel_title">エラーの通知</string>
-    <string name="notification_success_text">%2$d 個の内 %1$d 個のアプリがバックアップされました。タップすると詳細が表示されます。</string>
+    <string name="notification_success_text">%2$d 個の内 %1$d 個のアプリがバックアップされました (%3$s)。タップすると詳細が表示されます。</string>
     <string name="notification_channel_title">バックアップの通知</string>
     <string name="recovery_code_verification_ok_message">コードは正しく、バックアップを復元するために機能します。</string>
     <string name="recovery_code_error_invalid_word">単語が間違っています。</string>
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 1067ca7..383dd9e 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -52,7 +52,7 @@
     <string name="notification_error_title">백업 오류</string>
     <string name="notification_error_channel_title">오류 알림</string>
     <string name="notification_failed_title">백업 실패</string>
-    <string name="notification_success_text">%2$d개 중 %1$d개 앱을 백업했습니다. 여기를 눌러서 더 알아보세요.</string>
+    <string name="notification_success_text">%2$d개 중 %1$d개 앱을 백업했습니다 (%3$s). 여기를 눌러서 더 알아보세요.</string>
     <string name="notification_success_title">백업 완료</string>
     <string name="notification_title">백업 실행 중</string>
     <string name="notification_channel_title">백업 알림</string>
diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml
index 7f29811..49090cc 100644
--- a/app/src/main/res/values-lt/strings.xml
+++ b/app/src/main/res/values-lt/strings.xml
@@ -58,7 +58,7 @@
     <string name="notification_error_title">Atsarginės kopijos kūrimo klaida</string>
     <string name="notification_error_channel_title">Klaidos pranešimas</string>
     <string name="notification_failed_title">Atsarginė kopija nepavyko</string>
-    <string name="notification_success_text">%1$d iš %2$d programėlių atsarginės kopijos sukurtos. Palieskite, kad sužinotumėte daugiau.</string>
+    <string name="notification_success_text">%1$d iš %2$d programėlių atsarginės kopijos sukurtos (%3$s). Palieskite, kad sužinotumėte daugiau.</string>
     <string name="notification_success_title">Atsarginė kopija baigta</string>
     <string name="notification_backup_already_running">Jau yra kuriama atsarginė kopija</string>
     <string name="notification_title">Atsarginis kopijavimas veikia</string>
diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml
index abfd7bd..65dc346 100644
--- a/app/src/main/res/values-nb-rNO/strings.xml
+++ b/app/src/main/res/values-nb-rNO/strings.xml
@@ -101,7 +101,7 @@
     <string name="settings_backup">Sikkerhetskopier dataen min</string>
     <string name="restore_backup_button">Gjenopprett sikkerhetskopi</string>
     <string name="backup">Sikkerhetskopier</string>
-    <string name="notification_success_text">%1$d av %2$d programmer sikkerhetskopiert. Trykk for mer info.</string>
+    <string name="notification_success_text">%1$d av %2$d programmer sikkerhetskopiert (%3$s). Trykk for mer info.</string>
     <string name="notification_backup_already_running">Sikkerhetskopi allerede underveis</string>
     <string name="settings_backup_status_title">Sikkerhetskopieringsstatus</string>
     <string name="recovery_code_verification_error_title">Feil gjenopprettingskode</string>
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index d175daa..18e1adb 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -1,10 +1,10 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <color name="accent">@*android:color/accent_device_default_dark</color>
-    <color name="primary">@*android:color/primary_device_default_settings</color>
-    <color name="primaryDark">@android:color/black</color>
-    <color name="background">@color/primaryDark</color>
-    <color name="actionBarPrimary">@color/background</color>
-    <color name="statusBarColor">@android:color/transparent</color>
-    <color name="red">@*android:color/error_color_device_default_dark</color>
+    <!-- AOSP colors -->
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#36 -->
+    <color name="accent">@android:color/system_accent1_100</color>
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#22 -->
+    <color name="primary">@android:color/system_neutral1_900</color>
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#51 -->
+    <color name="background">@android:color/system_neutral1_900</color>
 </resources>
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 729a591..ab9ca10 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -153,7 +153,7 @@
     <string name="backup_contacts">Lokale contacten</string>
     <string name="notification_restore_error_text">Plug je %1$s in voordat je de app installeert om de gegevens daarvan te herstellen uit je back-up.</string>
     <string name="notification_restore_error_title">Gegevens voor %1$s konden niet worden hersteld</string>
-    <string name="notification_success_text">%1$d van de %2$d apps zijn geback-upt. Tik om meer te weten te komen.</string>
+    <string name="notification_success_text">%1$d van de %2$d apps zijn geback-upt (%3$s). Tik om meer te weten te komen.</string>
     <string name="notification_error_text">Een apparaatback-up kon niet worden gestart.</string>
     <string name="notification_error_no_main_key_text">Maak een nieuwe herstelcode aan om de upgrade af te ronden en door te gaan met het maken van back-ups.</string>
     <string name="restore_app_not_yet_backed_up">Was nog niet geback-upt</string>
@@ -203,4 +203,5 @@
     <string name="about_contributing_organizations_title">Bijdragende organisaties</string>
     <string name="about_contributing_organizations_content"><a href="https://www.calyxinstitute.org">Calyx Institute</a> voor gebruik in <a href="https://calyxos.org">CalyxOS</a>
 \n<a href="https://nlnet.nl/project/Seedvault/">NGI0 PET Fund van NLnet</a></string>
+    <string name="storage_user_selected_location_title">Door gebruiker gekozen locatie</string>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index d3a003f..17fc23a 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -99,10 +99,12 @@
     <string name="backup_settings">Ustawienia urządzenia</string>
     <string name="backup_sms">Wiadomości tekstowe SMS</string>
     <string name="backup_section_system">Aplikacje systemowe</string>
-    <string name="notification_success_text">Skopiowano %1$d z %2$d aplikacji. Naciśnij, aby dowiedzieć się więcej.</string>
+    <string name="notification_success_text">Skopiowano %1$d z %2$d aplikacji (%3$s). Naciśnij, aby dowiedzieć się więcej.</string>
     <string name="notification_backup_already_running">Kopia zapasowa jest już tworzona</string>
     <string name="storage_fake_nextcloud_summary_unavailable">Konto nie jest dostępne. Skonfiguruj jakieś (lub wyłącz hasło).</string>
-    <string name="about_summary">Aplikacja do tworzenia kopii zapasowych wykorzystująca wewnętrzny interfejs API systemu Android do tworzenia kopii zapasowych.</string>
+    <string name="about_summary">Aplikacja do tworzenia kopii zapasowych wykorzystująca wewnętrzny interfejs API systemu Android. To Wolne Oprogramowanie, wydane na licencji Apache 2.
+\n
+\nJak każde oprogramowanie, Seedvault może zawierać błędy i luki w zabezpieczeniach.</string>
     <string name="about_title">Informacje</string>
     <string name="storage_internal_warning_use_anyway">Użyj mimo tego</string>
     <string name="storage_internal_warning_choose_other">Wybierz inną</string>
@@ -181,4 +183,21 @@
     <string name="settings_expert_quota_title">Nieograniczona liczba aplikacji</string>
     <string name="settings_expert_logcat_title">Zapisz dziennik aplikacji</string>
     <string name="settings_expert_logcat_error">Błąd: Nie można zapisać dziennika aplikacji</string>
+    <string name="storage_user_selected_location_title">Lokalizacja wybrana przez użytkownika</string>
+    <string name="restore_storage_got_it">Zrozumiano</string>
+    <string name="restore_storage_choose_snapshot">Wybierz kopię zapasową do przywrócenia (eksperymentalne)</string>
+    <string name="restore_storage_in_progress_info">Twoje pliki są przywracanę w tle. Możesz używać telefonu w trakcie przywracania.
+\n
+\nNiektóre aplikacje (np. Signal czy WhatsApp) mogę wymagać by pliki były w pełni przywrócona aby zaimportować kopię zapasową. Postaraj się nie włączć tych aplikacji przed zakończeniem przywracania.</string>
+    <string name="about_contributor_headline">Współautorzy</string>
+    <string name="settings_backup_dialog_message">Kiedy ponownie włączysz kopię zapasową, proces kopiowanie może zająć więcej czasu niż zwykle, oraz wykożystać dodatkową przestrzeń na dysku.</string>
+    <string name="settings_expert_quota_summary">Nie limituj wielkości kopi zapasowej aplikacji.
+\n
+\nUwaga: Ta opcja może w krótkim czasie zapełnić miejsce na dysku. Niepotrzebne większości aplikacji.</string>
+    <string name="storage_fake_davx5_summary_installed">Stuknij by ustawić punkt montażu WebDAV</string>
+    <string name="storage_fake_davx5_summary_unavailable">Punkt montażu WebDAV niedostępny. Ustaw go.</string>
+    <string name="settings_expert_logcat_summary">Deweloperzy mogą zdiagnozować błędy za pomocą informacji zawartych w raportach błędów.
+\n
+\nUwaga: Plik z raportem błędów może zawierać informacje na podstawie których można cię zidentyfikować. Zapoznaj się z nim i usuń go gdy nie będzie ci już potrzebny!</string>
+    <string name="recovery_code_auth_description">Wprowadź informacje swojego urządzenia aby kontynuować</string>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 7a56550..e49ed64 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -99,7 +99,7 @@
     <string name="restore_set_empty_result">Nenhum backup adequado encontrado no local dado.
 \n
 \nIsso provavelmente se deve a um código de recuperação errado ou a um erro de armazenamento.</string>
-    <string name="notification_success_text">%1$d de %2$d aplicativos com backup. Toque para saber mais.</string>
+    <string name="notification_success_text">%1$d de %2$d aplicativos com backup (%3$s). Toque para saber mais.</string>
     <string name="notification_backup_already_running">Backup já em andamento</string>
     <string name="restore_app_was_stopped">Não feito nenhum backup desde que não foi utilizado recentemente</string>
     <string name="restore_app_not_yet_backed_up">Nenhum backup foi feito ainda</string>
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 7284e01..01c835a 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -58,7 +58,7 @@
     <string name="notification_error_title">Erro de backup</string>
     <string name="notification_error_channel_title">Notificação de erro</string>
     <string name="notification_failed_title">Backup falhou</string>
-    <string name="notification_success_text">Feito o backup da app %1$d de %2$d . Toque para saber mais.</string>
+    <string name="notification_success_text">Feito o backup da app %1$d de %2$d (%3$s). Toque para saber mais.</string>
     <string name="notification_success_title">Backup concluído</string>
     <string name="notification_backup_already_running">Backup em andamento</string>
     <string name="notification_channel_title">Notificação de backup</string>
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index e9a7203..e05e5f1 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
-    <string name="notification_success_text">Feito o backup da app %1$d de %2$d . Toque para saber mais.</string>
+    <string name="notification_success_text">Feito o backup da app %1$d de %2$d (%3$s). Toque para saber mais.</string>
     <string name="notification_backup_already_running">Backup em andamento</string>
     <string name="about_summary">Uma app de backup usando a API de backup interna do Android.</string>
     <string name="about_title">Sobre</string>
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 23750f4..103a01a 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -6,7 +6,9 @@
     <string name="settings_backup_last_backup_never">Никогда</string>
     <string name="backup">Резервная копия</string>
     <string name="settings_backup_location">Расположение Резервной Копии</string>
-    <string name="about_summary">Приложение для резервного копирования, использующее API резервного копирования Android.</string>
+    <string name="about_summary">Приложение для резервного копирования, использующее API резервного копирования Android. Это свободное ПО, выпущенное под лицензией Apache 2.
+\n
+\nКак и любое другое ПО, Seedvault может иметь баги или уязвимости.</string>
     <string name="about_title">О приложении</string>
     <string name="storage_internal_warning_use_anyway">Все равно использовать</string>
     <string name="storage_internal_warning_choose_other">Выбрать другое</string>
@@ -99,7 +101,7 @@
     <string name="settings_info">Все резервные копии на вашем телефоне зашифрованы. Для восстановления из резервной копии вам понадобится код восстановления из 12 слов.</string>
     <string name="settings_backup_location_none">Ни одно</string>
     <string name="restore_backup_button">Восстановление резервной копии</string>
-    <string name="notification_success_text">Резервное копирование %1$d из %2$d приложений выполнено. Нажмите, чтобы узнать больше.</string>
+    <string name="notification_success_text">Резервное копирование %1$d из %2$d приложений выполнено (%3$s). Нажмите, чтобы узнать больше.</string>
     <string name="notification_backup_already_running">Резервное копирование уже выполняется</string>
     <string name="restore_app_not_yet_backed_up">Резервная копия ещё не создавалась</string>
     <string name="restore_app_was_stopped">Резервная копия не сохранена, поскольку приложение давно не использовалось</string>
@@ -192,4 +194,12 @@
 \n
 \nВнимание: Журнал может содержать личную информацию. Проверьте до и удалите после отправки!</string>
     <string name="settings_expert_logcat_error">Ошибка: Не удалось сохранить журнал приложения</string>
+    <string name="storage_not_recommended"><xliff:g example="Skynet">%1$s</xliff:g> (Не рекомендуется)</string>
+    <string name="storage_user_selected_location_title">Расположение, выбранное пользователем</string>
+    <string name="settings_backup_dialog_disable">Всё равно выключить</string>
+    <string name="settings_backup_dialog_message">Когда резервные копии будут включены заново, создание резервной копии займёт дольше, чем обычно, и она займёт больше места.</string>
+    <string name="settings_backup_dialog_title">Точно выключить резервные копии приложений\?</string>
+    <string name="about_contributing_organizations_title">Участвующие организации</string>
+    <string name="about_contributor_headline">Участники</string>
+    <string name="about_contributor_content"><a href="https://github.com/seedvault-app/seedvault/graphs/contributors">Спискок участников</a> доступен на GitHub.</string>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index 8fd7076..e40c560 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -72,7 +72,7 @@
     <string name="notification_error_title">Chyba zálohovania</string>
     <string name="notification_error_channel_title">Oznámenie o chybe</string>
     <string name="notification_failed_title">Zálohovanie zlyhalo</string>
-    <string name="notification_success_text">Zálohované %1$d z %2$d aplikácií. Klepnutím získate ďalšie informácie.</string>
+    <string name="notification_success_text">Zálohované %1$d z %2$d aplikácií (%3$s). Klepnutím získate ďalšie informácie.</string>
     <string name="notification_success_title">Zálohovanie ukončené</string>
     <string name="notification_backup_already_running">Zálohovanie už prebieha</string>
     <string name="notification_title">Zálohovanie je spustené</string>
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
index 58bc2d0..e4d77ac 100644
--- a/app/src/main/res/values-ta/strings.xml
+++ b/app/src/main/res/values-ta/strings.xml
@@ -58,7 +58,7 @@
     <string name="notification_error_title">காப்புப் பிழை</string>
     <string name="notification_error_channel_title">பிழை அறிவிப்பு</string>
     <string name="notification_failed_title">காப்புப் பிரதி எடுக்க முடியவில்லை</string>
-    <string name="notification_success_text">%1$d / %2$d பயன்பாடுகள் காப்புப் பிரதி எடுக்கப்பட்டது. மேலும் அறிய தட்டவும்.</string>
+    <string name="notification_success_text">%1$d / %2$d பயன்பாடுகள் காப்புப் பிரதி எடுக்கப்பட்டது (%3$s). மேலும் அறிய தட்டவும்.</string>
     <string name="notification_success_title">காப்புப்பிரதி முடிந்தது</string>
     <string name="notification_channel_title">காப்புப்பிரதி அறிவிப்பு</string>
     <string name="notification_title">காப்புப்பிரதி இயங்குகிறது</string>
diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml
index c3fa52b..961bba4 100644
--- a/app/src/main/res/values-te/strings.xml
+++ b/app/src/main/res/values-te/strings.xml
@@ -37,7 +37,7 @@
     <string name="notification_error_text">డివైస్ బ్యాకప్ అమలు చేయడంలో విఫలమైంది.</string>
     <string name="notification_error_title">బ్యాకప్ లోపం అయ్యింది</string>
     <string name="notification_failed_title">బ్యాకప్ విఫలమైంది</string>
-    <string name="notification_success_text">%2$d యాప్‌లలో %1$d బ్యాకప్ చేయబడినవి . మరింత తెలుసుకోవడానికి ఇక్కడ నొక్కండి.</string>
+    <string name="notification_success_text">%2$d యాప్‌లలో %1$d బ్యాకప్ చేయబడినవి (%3$s) . మరింత తెలుసుకోవడానికి ఇక్కడ నొక్కండి.</string>
     <string name="notification_success_title">బ్యాకప్ పూర్తయింది</string>
     <string name="notification_backup_already_running">బ్యాకప్ ఇప్పటికే ప్రారంభించబడింది</string>
     <string name="notification_title">బ్యాకప్ జరుగుతుంది</string>
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index cd80d61..1cc74b4 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -103,7 +103,7 @@
     <string name="restore_backup_button">Yedeklemeyi geri yükle</string>
     <string name="restore_app_was_stopped">Yakın zamanda kullanılmadığı için yedeklenmemişti</string>
     <string name="restore_app_not_yet_backed_up">Henüz yedeklenmemişti</string>
-    <string name="notification_success_text">%1$d / %2$d uygulama yedeklendi. Daha fazlasını öğrenmek için dokunun.</string>
+    <string name="notification_success_text">%1$d / %2$d uygulama yedeklendi (%3$s). Daha fazlasını öğrenmek için dokunun.</string>
     <string name="notification_backup_already_running">Yedekleme zaten devam ediyor</string>
     <string name="storage_fake_nextcloud_summary_unavailable">Kullanılabilir hesap yok. Bir tane ayarlayın (veya parolayı devre dışı bırakın).</string>
     <string name="current_destination_string">Yedekleme durumu ve ayarları</string>
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 09463c7..53b03f6 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -95,7 +95,7 @@
     <string name="notification_error_title">Помилка резервного копіювання</string>
     <string name="notification_error_channel_title">Сповіщення про помилку</string>
     <string name="notification_failed_title">Не вдалося виконати резервне копіювання</string>
-    <string name="notification_success_text">Резервне копіювання %1$d з %2$d застосунків виконано. Натисніть, щоб дізнатися більше.</string>
+    <string name="notification_success_text">Резервне копіювання %1$d з %2$d застосунків виконано (%3$s). Натисніть, щоб дізнатися більше.</string>
     <string name="notification_success_title">Резервне копіювання завершено</string>
     <string name="notification_backup_already_running">Резервне копіювання вже виконується</string>
     <string name="notification_title">Резервне копіювання запущено</string>
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 16906db..536d919 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -58,7 +58,7 @@
     <string name="notification_error_title">Lỗi sao lưu</string>
     <string name="notification_error_channel_title">Thông báo lỗi</string>
     <string name="notification_failed_title">Sao lưu thất bại</string>
-    <string name="notification_success_text">Đã sao lưu %1$d trong số %2$d ứng dụng. Nhấn để tìm hiểu thêm.</string>
+    <string name="notification_success_text">Đã sao lưu %1$d trong số %2$d ứng dụng (%3$s). Nhấn để tìm hiểu thêm.</string>
     <string name="notification_success_title">Sao lưu hoàn tất</string>
     <string name="notification_backup_already_running">Bản sao lưu đã đang được thực hiện rồi</string>
     <string name="notification_title">Bản sao lưu đang chạy</string>
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 3c8d38d..86a68a7 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -103,7 +103,7 @@
     <string name="settings_auto_restore_summary_usb">注意:要让该功能正常工作,需插入你的%1$s。</string>
     <string name="restore_app_was_stopped">最近没有使用,未备份</string>
     <string name="restore_app_not_yet_backed_up">未备份</string>
-    <string name="notification_success_text">%2$d个应用中的%1$d个已备份。点击了解更多。</string>
+    <string name="notification_success_text">%2$d个应用中的%1$d个已备份 (%3$s)。点击了解更多。</string>
     <string name="notification_backup_already_running">备份已在进行</string>
     <string name="storage_fake_nextcloud_summary_unavailable">账户不可用。设置一个(或禁用密码)。</string>
     <string name="current_destination_string">备份状态与备份设置</string>
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index dd7e05f..bab2533 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,14 +1,25 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <color name="accent">@*android:color/accent_device_default_light</color>
-    <color name="primary">@*android:color/primary_device_default_settings_light</color>
-    <color name="primaryDark">@*android:color/primary_dark_device_default_settings_light</color>
-    <color name="background">@*android:color/background_device_default_light</color>
-    <color name="actionBarPrimary">@*android:color/primary_device_default_light</color>
-    <color name="statusBarColor">@*android:color/primary_device_default_settings_light
-    </color>
-    <color name="red">@*android:color/error_color_device_default_dark</color>
-    <color name="ic_launcher_background">@*android:color/accent_device_default_light</color>
+    <!-- AOSP colors -->
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#35 -->
+    <color name="accent">@android:color/system_accent1_600</color>
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#23 -->
+    <color name="primary">@android:color/system_neutral1_50</color>
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#27 -->
+    <color name="primaryDark">@color/primary</color>
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#52 -->
+    <color name="background">@android:color/system_neutral1_50</color>
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#21 -->
+    <color name="actionBarPrimary">@color/primary</color>
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#23 -->
+    <color name="statusBarColor">@color/primary</color>
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#69 -->
+    <!-- private resource, access it from colorError attribute instead -->
+    <color name="red">?android:attr/colorError</color>
+    <!-- https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/res/res/values/colors_device_defaults.xml#35 -->
+    <color name="ic_launcher_background">@color/accent</color>
+
+    <!-- Custom colors -->
     <color name="divider">#20ffffff</color>
     <color name="green">#558B2F</color>
     <color name="yellow">#F9A825</color>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 502ba39..a5a5d55 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -32,14 +32,14 @@
     <string name="settings_backup_status_summary">Last backup: %1$s</string>
     <string name="settings_backup_exclude_apps">Exclude apps</string>
     <string name="settings_backup_now">Backup now</string>
-    <string name="settings_category_storage">Storage backup (experimental)</string>
+    <string name="settings_category_storage">Storage backup (beta)</string>
     <string name="settings_backup_storage_title">Backup my files</string>
     <string name="settings_backup_files_title">Included files and folders</string>
     <string name="settings_backup_files_summary">None</string>
     <string name="settings_backup_recovery_code">Recovery code</string>
     <string name="settings_backup_recovery_code_summary">Verify existing code or generate a new one</string>
-    <string name="settings_backup_storage_dialog_title">Experimental feature</string>
-    <string name="settings_backup_storage_dialog_message">Backing up files is still experimental and might not work. Do not rely on it for important data.</string>
+    <string name="settings_backup_storage_dialog_title">Beta feature</string>
+    <string name="settings_backup_storage_dialog_message">Backing up files is beta and might not work. Do not rely on it for important data.</string>
     <string name="settings_backup_storage_dialog_ok">Enable anyway</string>
     <string name="settings_backup_storage_battery_optimization">Warning: No automatic backups, because battery optimization is active.</string>
     <string name="settings_backup_new_code_dialog_title">New recovery code required</string>
@@ -49,6 +49,8 @@
     <string name="settings_expert_title">Expert settings</string>
     <string name="settings_expert_quota_title">Unlimited app quota</string>
     <string name="settings_expert_quota_summary">Do not impose a limitation on the size of app backups.\n\nWarning: This can fill up your storage location quickly. Not needed for most apps.</string>
+    <string name="settings_expert_d2d_title">Device-to-device backups</string>
+    <string name="settings_expert_d2d_summary">This forces backups for most apps, even when they disallow them. This is alpha, use at your own risk.</string>
     <string name="settings_expert_logcat_title">Save app log</string>
     <string name="settings_expert_logcat_summary">Developers can diagnose bugs with these logs.\n\nWarning: The log file might contain personally identifiable information. Review before and delete after sharing!</string>
     <string name="settings_expert_logcat_error">Error: Could not save app log</string>
@@ -117,12 +119,13 @@
 
     <!-- Notification -->
     <string name="notification_channel_title">Backup notification</string>
+    <string name="notification_success_channel_title">Success notification</string>
     <string name="notification_title">Backup running</string>
     <string name="notification_backup_already_running">Backup already in progress</string>
     <string name="notification_backup_disabled">Backup not enabled</string>
 
     <string name="notification_success_title">Backup finished</string>
-    <string name="notification_success_text">%1$d of %2$d apps backed up. Tap to learn more.</string>
+    <string name="notification_success_text">%1$d of %2$d apps backed up (%3$s). Tap to learn more.</string>
     <string name="notification_failed_title">Backup failed</string>
 
     <string name="notification_error_channel_title">Error notification</string>
@@ -192,7 +195,7 @@
     <string name="restore_finished_button">Finish</string>
 
     <string name="restore_storage_skip">Skip restoring files</string>
-    <string name="restore_storage_choose_snapshot">Choose a storage backup to restore (experimental)</string>
+    <string name="restore_storage_choose_snapshot">Choose a storage backup to restore (beta)</string>
     <string name="restore_storage_in_progress_title">Files are being restored…</string>
     <string name="restore_storage_in_progress_info">Your files are being restored in the background. You can start using your phone while this is running.\n\nSome apps (e.g. Signal or WhatsApp) might require files to be fully restored to import a backup. Try to avoid starting those apps before file restore is complete.</string>
     <string name="restore_storage_got_it">Got it</string>
diff --git a/app/src/main/res/xml/settings_expert.xml b/app/src/main/res/xml/settings_expert.xml
index 11e8497..0125bf4 100644
--- a/app/src/main/res/xml/settings_expert.xml
+++ b/app/src/main/res/xml/settings_expert.xml
@@ -5,6 +5,12 @@
         android:key="unlimited_quota"
         android:summary="@string/settings_expert_quota_summary"
         android:title="@string/settings_expert_quota_title" />
+    <SwitchPreferenceCompat
+        android:id="@+id/d2d_backup_preference"
+        android:defaultValue="false"
+        android:key="d2d_backups"
+        android:summary="@string/settings_expert_d2d_summary"
+        android:title="@string/settings_expert_d2d_title" />
     <Preference
         android:icon="@drawable/ic_bug_report"
         android:key="logcat"
diff --git a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
index b1ad45a..cdf03ae 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/TestApp.kt
@@ -10,6 +10,7 @@
 import com.stevesoltys.seedvault.metadata.metadataModule
 import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
 import com.stevesoltys.seedvault.restore.install.installModule
+import com.stevesoltys.seedvault.settings.SettingsManager
 import com.stevesoltys.seedvault.transport.backup.backupModule
 import com.stevesoltys.seedvault.transport.restore.restoreModule
 import org.koin.android.ext.koin.androidContext
@@ -25,6 +26,7 @@
     }
     private val appModule = module {
         single { Clock() }
+        single { SettingsManager(this@TestApp) }
     }
 
     override fun startKoin() = startKoin {
diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt
index 661677a..d127713 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt
@@ -19,6 +19,7 @@
 import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
 import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
 import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
+import com.stevesoltys.seedvault.settings.SettingsManager
 import io.mockk.Runs
 import io.mockk.every
 import io.mockk.just
@@ -26,7 +27,10 @@
 import io.mockk.verify
 import org.junit.After
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
 import org.junit.Assert.fail
+import org.junit.Before
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.koin.core.context.stopKoin
@@ -51,8 +55,16 @@
     private val crypto: Crypto = mockk()
     private val metadataWriter: MetadataWriter = mockk()
     private val metadataReader: MetadataReader = mockk()
+    private val settingsManager: SettingsManager = mockk()
 
-    private val manager = MetadataManager(context, clock, crypto, metadataWriter, metadataReader)
+    private val manager = MetadataManager(
+        context = context,
+        clock = clock,
+        crypto = crypto,
+        metadataWriter = metadataWriter,
+        metadataReader = metadataReader,
+        settingsManager = settingsManager
+    )
 
     private val time = 42L
     private val token = Random.nextLong()
@@ -69,6 +81,11 @@
     private val cacheInputStream: FileInputStream = mockk()
     private val encodedMetadata = getRandomByteArray()
 
+    @Before
+    fun beforeEachTest() {
+        every { settingsManager.d2dBackupsEnabled() } returns false
+    }
+
     @After
     fun afterEachTest() {
         stopKoin()
@@ -232,6 +249,7 @@
             time = time,
             packageMetadataMap = PackageMetadataMap() // otherwise this isn't copied, but referenced
         )
+        val size = Random.nextLong()
         val packageMetadata = PackageMetadata(time)
         updatedMetadata.packageMetadataMap[packageName] = packageMetadata
 
@@ -239,13 +257,36 @@
         every { clock.time() } returns time
         expectModifyMetadata(initialMetadata)
 
-        manager.onPackageBackedUp(packageInfo, BackupType.FULL, storageOutputStream)
+        manager.onPackageBackedUp(packageInfo, BackupType.FULL, size, storageOutputStream)
 
         assertEquals(
-            packageMetadata.copy(state = APK_AND_DATA, backupType = BackupType.FULL, system = true),
+            packageMetadata.copy(
+                state = APK_AND_DATA,
+                backupType = BackupType.FULL,
+                size = size,
+                system = true,
+            ),
             manager.getPackageMetadata(packageName)
         )
         assertEquals(time, manager.getLastBackupTime())
+        assertFalse(updatedMetadata.d2dBackup)
+
+        verify {
+            cacheInputStream.close()
+            cacheOutputStream.close()
+        }
+    }
+
+    @Test
+    fun `test onPackageBackedUp() with D2D enabled`() {
+        expectReadFromCache()
+        every { clock.time() } returns time
+        expectModifyMetadata(initialMetadata)
+
+        every { settingsManager.d2dBackupsEnabled() } returns true
+
+        manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L, storageOutputStream)
+        assertTrue(initialMetadata.d2dBackup)
 
         verify {
             cacheInputStream.close()
@@ -256,19 +297,20 @@
     @Test
     fun `test onPackageBackedUp() fails to write to storage`() {
         val updateTime = time + 1
+        val size = Random.nextLong()
         val updatedMetadata = initialMetadata.copy(
             time = updateTime,
             packageMetadataMap = PackageMetadataMap() // otherwise this isn't copied, but referenced
         )
         updatedMetadata.packageMetadataMap[packageName] =
-            PackageMetadata(updateTime, APK_AND_DATA, BackupType.KV)
+            PackageMetadata(updateTime, APK_AND_DATA, BackupType.KV, size)
 
         expectReadFromCache()
         every { clock.time() } returns updateTime
         every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException()
 
         try {
-            manager.onPackageBackedUp(packageInfo, BackupType.KV, storageOutputStream)
+            manager.onPackageBackedUp(packageInfo, BackupType.KV, size, storageOutputStream)
             fail()
         } catch (e: IOException) {
             // expected
@@ -301,7 +343,7 @@
         every { clock.time() } returns time
         expectModifyMetadata(updatedMetadata)
 
-        manager.onPackageBackedUp(packageInfo, BackupType.FULL, storageOutputStream)
+        manager.onPackageBackedUp(packageInfo, BackupType.FULL, 0L, storageOutputStream)
 
         assertEquals(time, manager.getLastBackupTime())
         assertEquals(PackageMetadata(time), manager.getPackageMetadata(cachedPackageName))
diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt
index 4712551..32d3224 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt
@@ -14,6 +14,7 @@
 import org.junit.jupiter.api.TestInstance
 import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS
 import kotlin.random.Random
+import kotlin.random.nextLong
 
 @TestInstance(PER_CLASS)
 internal class MetadataWriterDecoderTest {
@@ -25,7 +26,10 @@
 
     @Test
     fun `encoded metadata matches decoded metadata (no packages)`() {
-        val metadata = getMetadata()
+        val metadata = getMetadata().let {
+            if (it.version == 0.toByte()) it.copy(salt = "") // no salt in version 0
+            else it
+        }
         assertEquals(
             metadata,
             decoder.decode(encoder.encode(metadata), metadata.version, metadata.token)
@@ -81,11 +85,12 @@
                     time = Random.nextLong(),
                     state = QUOTA_EXCEEDED,
                     backupType = BackupType.FULL,
+                    size = Random.nextLong(0..Long.MAX_VALUE),
                     system = Random.nextBoolean(),
                     version = Random.nextLong(),
                     installer = getRandomString(),
                     sha256 = getRandomString(),
-                    signatures = listOf(getRandomString())
+                    signatures = listOf(getRandomString()),
                 )
             )
             put(
@@ -93,22 +98,24 @@
                     time = Random.nextLong(),
                     state = NO_DATA,
                     backupType = BackupType.KV,
+                    size = null,
                     system = Random.nextBoolean(),
                     version = Random.nextLong(),
                     installer = getRandomString(),
                     sha256 = getRandomString(),
-                    signatures = listOf(getRandomString(), getRandomString())
+                    signatures = listOf(getRandomString(), getRandomString()),
                 )
             )
             put(
                 getRandomString(), PackageMetadata(
                     time = 0L,
                     state = NOT_ALLOWED,
+                    size = 0,
                     system = Random.nextBoolean(),
                     version = Random.nextLong(),
                     installer = getRandomString(),
                     sha256 = getRandomString(),
-                    signatures = listOf(getRandomString(), getRandomString())
+                    signatures = listOf(getRandomString(), getRandomString()),
                 )
             )
         }
diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt
index f8805c7..f712807 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt
@@ -107,6 +107,7 @@
             writeBytes(splitBytes)
         }.absolutePath)
 
+        every { settingsManager.isBackupEnabled(any()) } returns true
         every { settingsManager.backupApks() } returns true
         every { sigInfo.hasMultipleSigners() } returns false
         every { sigInfo.signingCertificateHistory } returns sigs
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
index 824230e..0ff406d 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt
@@ -147,7 +147,12 @@
             metadataManager.onApkBackedUp(packageInfo, packageMetadata, metadataOutputStream)
         } just Runs
         every {
-            metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream)
+            metadataManager.onPackageBackedUp(
+                packageInfo = packageInfo,
+                type = BackupType.KV,
+                size = more((appData.size + appData2.size).toLong()), // more because DB overhead
+                metadataOutputStream = metadataOutputStream,
+            )
         } just Runs
 
         // start K/V backup
@@ -216,7 +221,12 @@
             backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
         } returns metadataOutputStream
         every {
-            metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream)
+            metadataManager.onPackageBackedUp(
+                packageInfo = packageInfo,
+                type = BackupType.KV,
+                size = more(size.toLong()), // more than $size, because DB overhead
+                metadataOutputStream = metadataOutputStream,
+            )
         } just Runs
 
         // start K/V backup
@@ -289,7 +299,12 @@
             )
         } just Runs
         every {
-            metadataManager.onPackageBackedUp(packageInfo, BackupType.FULL, metadataOutputStream)
+            metadataManager.onPackageBackedUp(
+                packageInfo = packageInfo,
+                type = BackupType.FULL,
+                size = appData.size.toLong(),
+                metadataOutputStream = metadataOutputStream,
+            )
         } just Runs
 
         // perform backup to output stream
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt
index 35e81b2..0af1caa 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/TransportTest.kt
@@ -59,6 +59,10 @@
             put(packageInfo.packageName, PackageMetadata(backupType = BackupType.KV))
         }
     )
+    protected val d2dMetadata = metadata.copy(
+        d2dBackup = true
+    )
+
     protected val salt = metadata.salt
     protected val name = getRandomString(12)
     protected val name2 = getRandomString(23)
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt
index 2137e56..0cbbf9e 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt
@@ -62,6 +62,15 @@
     @Test
     fun `does not back up when setting disabled`() = runBlocking {
         every { settingsManager.backupApks() } returns false
+        every { settingsManager.isBackupEnabled(any()) } returns true
+
+        assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
+    }
+
+    @Test
+    fun `does not back up when app blacklisted`() = runBlocking {
+        every { settingsManager.backupApks() } returns true
+        every { settingsManager.isBackupEnabled(any()) } returns false
 
         assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
     }
@@ -70,8 +79,8 @@
     fun `does not back up test-only apps`() = runBlocking {
         packageInfo.applicationInfo.flags = FLAG_TEST_ONLY
 
+        every { settingsManager.isBackupEnabled(any()) } returns true
         every { settingsManager.backupApks() } returns true
-
         assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
     }
 
@@ -79,8 +88,8 @@
     fun `does not back up system apps`() = runBlocking {
         packageInfo.applicationInfo.flags = FLAG_SYSTEM
 
+        every { settingsManager.isBackupEnabled(any()) } returns true
         every { settingsManager.backupApks() } returns true
-
         assertNull(apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, streamGetter))
     }
 
@@ -112,6 +121,7 @@
     @Test
     fun `do not accept empty signature`() = runBlocking {
         every { settingsManager.backupApks() } returns true
+        every { settingsManager.isBackupEnabled(any()) } returns true
         every {
             metadataManager.getPackageMetadata(packageInfo.packageName)
         } returns packageMetadata
@@ -229,6 +239,7 @@
     }
 
     private fun expectChecks(packageMetadata: PackageMetadata = this.packageMetadata) {
+        every { settingsManager.isBackupEnabled(any()) } returns true
         every { settingsManager.backupApks() } returns true
         every {
             metadataManager.getPackageMetadata(packageInfo.packageName)
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
index 5a7ece9..30d2aa1 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
@@ -32,12 +32,11 @@
 import io.mockk.verify
 import kotlinx.coroutines.runBlocking
 import org.junit.jupiter.api.Assertions.assertEquals
-import org.junit.jupiter.api.Assertions.assertFalse
-import org.junit.jupiter.api.Assertions.assertTrue
 import org.junit.jupiter.api.Test
 import java.io.IOException
 import java.io.OutputStream
 import kotlin.random.Random
+import kotlin.random.nextLong
 
 @Suppress("BlockingMethodInNonBlockingContext")
 internal class BackupCoordinatorTest : BackupTest() {
@@ -46,8 +45,8 @@
     private val kv = mockk<KVBackup>()
     private val full = mockk<FullBackup>()
     private val apkBackup = mockk<ApkBackup>()
-    private val packageService: PackageService = mockk()
     private val notificationManager = mockk<BackupNotificationManager>()
+    private val packageService = mockk<PackageService>()
 
     private val backup = BackupCoordinator(
         context,
@@ -171,20 +170,6 @@
     }
 
     @Test
-    fun `isAppEligibleForBackup() exempts plugin provider and blacklisted apps`() {
-        every {
-            settingsManager.isBackupEnabled(packageInfo.packageName)
-        } returns true andThen false andThen true
-        every {
-            plugin.providerPackageName
-        } returns packageInfo.packageName andThen "new.package" andThen "new.package"
-
-        assertFalse(backup.isAppEligibleForBackup(packageInfo, true))
-        assertFalse(backup.isAppEligibleForBackup(packageInfo, true))
-        assertTrue(backup.isAppEligibleForBackup(packageInfo, true))
-    }
-
-    @Test
     fun `clearing KV backup data throws`() = runBlocking {
         every { settingsManager.getToken() } returns token
         every { metadataManager.salt } returns salt
@@ -220,14 +205,22 @@
 
     @Test
     fun `finish backup delegates to KV plugin if it has state`() = runBlocking {
+        val size = 0L
+
         every { kv.hasState() } returns true
         every { full.hasState() } returns false
         every { kv.getCurrentPackage() } returns packageInfo
         coEvery { kv.finishBackup() } returns TRANSPORT_OK
         every { settingsManager.getToken() } returns token
         coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
+        every { kv.getCurrentSize() } returns size
         every {
-            metadataManager.onPackageBackedUp(packageInfo, BackupType.KV, metadataOutputStream)
+            metadataManager.onPackageBackedUp(
+                packageInfo = packageInfo,
+                type = BackupType.KV,
+                size = size,
+                metadataOutputStream = metadataOutputStream,
+            )
         } just Runs
         every { metadataOutputStream.close() } just Runs
 
@@ -241,6 +234,7 @@
         every { kv.hasState() } returns true
         every { full.hasState() } returns false
         every { kv.getCurrentPackage() } returns pmPackageInfo
+        every { kv.getCurrentSize() } returns 42L
 
         coEvery { kv.finishBackup() } returns TRANSPORT_OK
         every { settingsManager.canDoBackupNow() } returns false
@@ -251,6 +245,7 @@
     @Test
     fun `finish backup delegates to full plugin if it has state`() = runBlocking {
         val result = Random.nextInt()
+        val size: Long? = null
 
         every { kv.hasState() } returns false
         every { full.hasState() } returns true
@@ -258,8 +253,14 @@
         every { full.finishBackup() } returns result
         every { settingsManager.getToken() } returns token
         coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
+        every { full.getCurrentSize() } returns size
         every {
-            metadataManager.onPackageBackedUp(packageInfo, BackupType.FULL, metadataOutputStream)
+            metadataManager.onPackageBackedUp(
+                packageInfo = packageInfo,
+                type = BackupType.FULL,
+                size = size,
+                metadataOutputStream = metadataOutputStream,
+            )
         } just Runs
         every { metadataOutputStream.close() } just Runs
 
@@ -391,6 +392,7 @@
             }
         )
         val packageMetadata: PackageMetadata = mockk()
+        val size = Random.nextLong(1L..Long.MAX_VALUE)
 
         every { settingsManager.canDoBackupNow() } returns true
         every { metadataManager.requiresInit } returns false
@@ -410,8 +412,14 @@
         every { kv.hasState() } returns true
         every { full.hasState() } returns false
         every { kv.getCurrentPackage() } returns pmPackageInfo
+        every { kv.getCurrentSize() } returns size
         every {
-            metadataManager.onPackageBackedUp(pmPackageInfo, BackupType.KV, metadataOutputStream)
+            metadataManager.onPackageBackedUp(
+                pmPackageInfo,
+                BackupType.KV,
+                size,
+                metadataOutputStream,
+            )
         } just Runs
         coEvery { kv.finishBackup() } returns TRANSPORT_OK
 
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/TestKvDbManager.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/TestKvDbManager.kt
index 7173f2f..9a31d18 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/TestKvDbManager.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/TestKvDbManager.kt
@@ -42,6 +42,10 @@
         return db != null
     }
 
+    override fun getDbSize(packageName: String): Long? {
+        return db?.serialize()?.toByteArray()?.size?.toLong()
+    }
+
     override fun deleteDb(packageName: String, isRestore: Boolean): Boolean {
         clearDb()
         return true
diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
index c7c11dd..88ba4c1 100644
--- a/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
+++ b/app/src/test/java/com/stevesoltys/seedvault/transport/restore/RestoreCoordinatorTest.kt
@@ -90,6 +90,15 @@
         assertEquals(metadata.deviceName, sets[0].device)
         assertEquals(metadata.deviceName, sets[0].name)
         assertEquals(metadata.token, sets[0].token)
+
+        every { metadataReader.readMetadata(inputStream, token) } returns d2dMetadata
+        every { metadataReader.readMetadata(inputStream, token + 1) } returns d2dMetadata
+
+        val d2dSets = restore.getAvailableRestoreSets() ?: fail()
+        assertEquals(2, d2dSets.size)
+        assertEquals(D2D_DEVICE_NAME, d2dSets[0].device)
+        assertEquals(metadata.deviceName, d2dSets[0].name)
+        assertEquals(metadata.token, d2dSets[0].token)
     }
 
     @Test
diff --git a/build.gradle b/build.gradle
deleted file mode 100644
index 13e210e..0000000
--- a/build.gradle
+++ /dev/null
@@ -1,33 +0,0 @@
-buildscript {
-    // 1.3.61 Android 11
-    // 1.4.30 Android 12
-    // 1.6.10 Android 13
-    // 1.7.20 Android 13 (QPR2)
-    // 1.8.10 Android 14
-    // Check:
-    // https://android.googlesource.com/platform/external/kotlinc/+/refs/tags/android-14.0.0_r1/build.txt
-    ext.aosp_kotlin_version = '1.8.10'  // 1.8.10-release-430 in AOSP
-    ext.kotlin_version = '1.8.10'
-}
-
-plugins {
-    id 'com.android.application' version '8.1.2' apply false
-    id 'com.android.library' version '8.1.2' apply false
-    id 'com.google.protobuf' version '0.9.4' apply false
-    id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
-    id 'org.jetbrains.kotlin.kapt' version "$kotlin_version" apply false
-    id 'org.jetbrains.dokka' version "$kotlin_version" apply false
-    id 'org.jlleitschuh.gradle.ktlint' version '11.5.0' apply false
-}
-
-ext {
-    compileSdk = 34
-    minSdk = 33
-    targetSdk = 34
-}
-
-apply from: 'gradle/dependencies.gradle'
-
-task clean(type: Delete) {
-    delete rootProject.buildDir
-}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..2a5dde5
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,35 @@
+import org.jlleitschuh.gradle.ktlint.KtlintExtension
+
+buildscript {
+    repositories {
+        google()
+    }
+}
+
+plugins {
+    id("com.android.application") version plugins.versions.androidGradle apply false
+    id("com.android.library") version plugins.versions.androidGradle apply false
+    id("com.google.protobuf") version plugins.versions.protobuf apply false
+    id("org.jetbrains.kotlin.android") version plugins.versions.kotlin apply false
+    id("org.jetbrains.kotlin.kapt") version plugins.versions.kotlin apply false
+    id("org.jetbrains.dokka") version plugins.versions.kotlin apply false
+    id("org.jlleitschuh.gradle.ktlint") version plugins.versions.ktlint apply false
+}
+
+tasks.register("clean", Delete::class) {
+    delete(rootProject.buildDir)
+}
+
+subprojects {
+    if (path != ":storage:demo") {
+        apply(plugin = "org.jlleitschuh.gradle.ktlint")
+
+        configure<KtlintExtension> {
+            version.set("0.42.1")
+            android.set(true)
+            enableExperimentalRules.set(false)
+            verbose.set(true)
+            disabledRules.set(listOf("import-ordering", "no-blank-line-before-rbrace", "indent"))
+        }
+    }
+}
diff --git a/build.libs.toml b/build.libs.toml
new file mode 100644
index 0000000..43a99c3
--- /dev/null
+++ b/build.libs.toml
@@ -0,0 +1,93 @@
+[metadata]
+
+[versions]
+# Android SDK versions
+compileSdk = "34"
+minSdk = "33"
+targetSdk = "34"
+
+# Test versions
+junit4 = "4.13.2"
+junit5 = "5.10.0" # careful, upgrading this can change a Cipher's IV size in tests!?
+mockk = "1.13.4" # newer versions require kotlin > 1.8.10
+espresso = "3.4.0"
+
+# Dependency versions below this are AOSP versions.
+# We use "strictly" to enforce the version cannot be overriden by transitive dependencies.
+# We need to enforce that the versions we use are the same as AOSP to ensure compatibility.
+
+# Kotlin versions
+# https://android.googlesource.com/platform/external/kotlinc/+/refs/tags/android-14.0.0_r29/build.txt
+aosp-kotlin = { strictly = "1.9.0" }
+
+# Lint versions
+lint-rules = { strictly = "0.1.0" }
+
+# Google versions
+# https://android.googlesource.com/platform/external/protobuf/+/refs/tags/android-14.0.0_r29/java/pom.xml#7
+protobuf = { strictly = "3.21.12" }
+# https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r29/current/extras/material-design-x/Android.bp#15
+material = { strictly = "1.7.0-alpha03" }
+# careful with upgrading tink, so old backups continue to be decryptable
+# https://github.com/tink-crypto/tink-java/releases
+tink = { strictly = "1.10.0" }
+
+# Coroutines versions
+# https://android.googlesource.com/platform/external/kotlinx.coroutines/+/refs/tags/android-14.0.0_r29/CHANGES.md
+coroutines = { strictly = "1.7.2" }
+
+# AndroidX versions
+# https://android.googlesource.com/platform/prebuilts/sdk/+/android-14.0.0_r29/current/androidx/m2repository/androidx/room/room-ktx?autodive=0
+room = { strictly = "2.6.1" } # 2.7.0-alpha01 but that's not released, yet
+# https://android.googlesource.com/platform/prebuilts/sdk/+/android-14.0.0_r29/current/androidx/m2repository/androidx/core/core-ktx?autodive=0
+androidx-core = { strictly = "1.13.0-alpha02" } # 1.13.0-alpha01 in AOSP, but uses code from alpha02
+# https://android.googlesource.com/platform/prebuilts/sdk/+/android-14.0.0_r29/current/androidx/m2repository/androidx/fragment/fragment-ktx?autodive=0
+androidx-fragment = { strictly = "1.7.0-alpha06" }
+# https://android.googlesource.com/platform/prebuilts/sdk/+/android-14.0.0_r29/current/androidx/m2repository/androidx/activity/activity-ktx?autodive=0
+androidx-activity = { strictly = "1.9.0-alpha01" }
+# https://android.googlesource.com/platform/prebuilts/sdk/+/android-14.0.0_r29/current/androidx/m2repository/androidx/preference/preference?autodive=0
+androidx-preference = { strictly = "1.2.0-alpha01" } # 1.3.0-alpha01 in AOSP but isn't released
+# https://android.googlesource.com/platform/prebuilts/sdk/+/android-14.0.0_r29/current/androidx/m2repository/androidx/lifecycle/lifecycle-viewmodel-ktx?autodive=0
+androidx-lifecycle-viewmodel-ktx = { strictly = "2.7.0-alpha02" }
+androidx-lifecycle-livedata-ktx = { strictly = "2.7.0-alpha02" }
+# https://android.googlesource.com/platform/prebuilts/sdk/+/android-14.0.0_r29/current/androidx/m2repository/androidx/constraintlayout/constraintlayout?autodive=0
+androidx-constraintlayout = { strictly = "2.2.0-alpha13" }
+# https://android.googlesource.com/platform/prebuilts/sdk/+/android-14.0.0_r29/current/androidx/m2repository/androidx/documentfile/documentfile?autodive=0
+androidx-documentfile = { strictly = "1.1.0-alpha01" } # 1.1.0-alpha02 in AOSP but isn't released
+# https://android.googlesource.com/platform/prebuilts/sdk/+/android-14.0.0_r29/current/androidx/m2repository/androidx/work/work-runtime-ktx?autodive=0
+androidx-work-runtime = { strictly = "2.10.0-alpha01" }
+
+[libraries]
+# Kotlin standard dependencies
+kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "aosp-kotlin" }
+kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "aosp-kotlin" }
+kotlin-stdlib-common = { module = "org.jetbrains.kotlin:kotlin-stdlib-common", version.ref = "aosp-kotlin" }
+
+# Lint dependencies
+thirdegg-lint-rules = { module = "com.github.thirdegg:lint-rules", version.ref = "lint-rules" }
+
+# Google dependencies
+google-tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink" }
+google-protobuf-javalite = { module = 'com.google.protobuf:protobuf-javalite', version.ref = 'protobuf' }
+google-material = { module = 'com.google.android.material:material', version.ref = 'material' }
+
+# Coroutines dependencies
+kotlinx-coroutines-core-jvm = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version.ref = "coroutines" }
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
+
+# AndroidX dependencies
+androidx-core = { module = "androidx.core:core", version.ref = "androidx-core" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" }
+androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" }
+androidx-preference = { module = "androidx.preference:preference", version.ref = "androidx-preference" }
+androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-viewmodel-ktx" }
+androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle-livedata-ktx" }
+androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintlayout" }
+androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "androidx-documentfile" }
+androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work-runtime" }
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
+
+[bundles]
+kotlin = ["kotlin-stdlib", "kotlin-stdlib-jdk8", "kotlin-stdlib-common"]
+coroutines = ["kotlinx-coroutines-core-jvm", "kotlinx-coroutines-android"]
diff --git a/build.plugins.toml b/build.plugins.toml
new file mode 100644
index 0000000..6b67238
--- /dev/null
+++ b/build.plugins.toml
@@ -0,0 +1,15 @@
+[versions]
+# We need to enforce that the versions we use are the same as AOSP to ensure compatibility.
+# 1.3.61 Android 11
+# 1.4.30 Android 12
+# 1.6.10 Android 13
+# 1.7.20 Android 13 (QPR2)
+# 1.8.10 Android 14
+# 1.9.0 Android 14 (QPR2)
+# Check:
+# https://android.googlesource.com/platform/external/kotlinc/+/refs/tags/android-14.0.0_r29/build.txt
+kotlin = "1.9.0"
+
+androidGradle = "8.1.2"
+protobuf = "0.9.4"
+ktlint = "11.5.0"
diff --git a/contactsbackup/build.gradle b/contactsbackup/build.gradle
deleted file mode 100644
index e68d2df..0000000
--- a/contactsbackup/build.gradle
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2020 The Calyx Institute
- * SPDX-License-Identifier: Apache-2.0
- */
-
-plugins {
-    id 'com.android.application'
-    id 'org.jetbrains.kotlin.android'
-}
-
-android {
-    namespace 'org.calyxos.backup.contacts'
-    compileSdk rootProject.ext.compileSdk
-
-    defaultConfig {
-        applicationId "org.calyxos.backup.contacts"
-        minSdk rootProject.ext.minSdk
-        targetSdk rootProject.ext.targetSdk
-
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-        testInstrumentationRunnerArguments disableAnalytics: 'true'
-    }
-
-    compileOptions {
-        sourceCompatibility = JavaVersion.VERSION_17
-        targetCompatibility = JavaVersion.VERSION_17
-    }
-
-    kotlinOptions {
-        jvmTarget = JavaVersion.VERSION_17.toString()
-    }
-
-    packagingOptions {
-        exclude("META-INF/LICENSE.md")
-        exclude("META-INF/LICENSE-notice.md")
-    }
-
-    testOptions {
-        unitTests.returnDefaultValues = true
-    }
-
-    // optional signingConfigs
-    // On userdebug builds, you can use the testkey here to update the system app
-    def keystorePropertiesFile = project.file("keystore.properties")
-    if (keystorePropertiesFile.exists()) {
-        def keystoreProperties = new Properties()
-        keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
-
-        signingConfigs {
-            release {
-                keyAlias keystoreProperties['keyAlias']
-                keyPassword keystoreProperties['keyPassword']
-                storeFile file(keystoreProperties['storeFile'])
-                storePassword keystoreProperties['storePassword']
-            }
-        }
-        buildTypes.release.signingConfig = signingConfigs.release
-        buildTypes.debug.signingConfig = signingConfigs.release
-    }
-}
-
-def aospDeps = fileTree(include: [
-        // out/target/common/obj/JAVA_LIBRARIES/com.android.vcard_intermediates/classes.jar
-        'com.android.vcard.jar'
-], dir: 'libs')
-
-dependencies {
-    implementation aospDeps
-
-    testImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
-    testImplementation "junit:junit:$junit4_version"
-    testImplementation "io.mockk:mockk:$mockk_version"
-
-    androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
-    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
-    androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
-    androidTestImplementation "io.mockk:mockk-android:$mockk_version"
-}
diff --git a/contactsbackup/build.gradle.kts b/contactsbackup/build.gradle.kts
new file mode 100644
index 0000000..92ad822
--- /dev/null
+++ b/contactsbackup/build.gradle.kts
@@ -0,0 +1,78 @@
+/*
+ * SPDX-FileCopyrightText: 2020 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import java.io.FileInputStream
+import java.util.Properties
+
+plugins {
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+}
+
+android {
+    namespace = "org.calyxos.backup.contacts"
+    compileSdk = libs.versions.compileSdk.get().toInt()
+
+    defaultConfig {
+        applicationId = "org.calyxos.backup.contacts"
+        minSdk = libs.versions.minSdk.get().toInt()
+        targetSdk = libs.versions.targetSdk.get().toInt()
+
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        testInstrumentationRunnerArguments(mapOf("disableAnalytics" to "true"))
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.toString()
+    }
+
+    packagingOptions {
+        exclude("META-INF/LICENSE.md")
+        exclude("META-INF/LICENSE-notice.md")
+    }
+
+    testOptions.unitTests {
+        isReturnDefaultValues = true
+    }
+
+    // optional signingConfigs
+    // On userdebug builds, you can use the testkey here to update the system app
+    val keystorePropertiesFile = project.file("keystore.properties")
+    if (keystorePropertiesFile.exists()) {
+        val keystoreProperties = Properties()
+        keystoreProperties.load(FileInputStream(keystorePropertiesFile))
+
+        signingConfigs {
+            create("release") {
+                keyAlias = keystoreProperties["keyAlias"] as String
+                keyPassword = keystoreProperties["keyPassword"] as String
+                storeFile = file(keystoreProperties["storeFile"] as String)
+                storePassword = keystoreProperties["storePassword"] as String
+            }
+        }
+        buildTypes.getByName("release").signingConfig = signingConfigs.getByName("release")
+        buildTypes.getByName("debug").signingConfig = signingConfigs.getByName("release")
+    }
+}
+
+val aospDeps = fileTree(mapOf("include" to listOf("com.android.vcard.jar"), "dir" to "libs"))
+
+dependencies {
+    implementation(aospDeps)
+
+    testImplementation(libs.kotlin.stdlib.jdk8)
+    testImplementation("junit:junit:${libs.versions.junit4.get()}")
+    testImplementation("io.mockk:mockk:${libs.versions.mockk.get()}")
+
+    androidTestImplementation(libs.kotlin.stdlib.jdk8)
+    androidTestImplementation("androidx.test.ext:junit:1.1.5")
+    androidTestImplementation(
+        "androidx.test.espresso:espresso-core:${libs.versions.espresso.get()}")
+    androidTestImplementation("io.mockk:mockk-android:${libs.versions.mockk.get()}")
+}
diff --git a/contactsbackup/src/main/AndroidManifest.xml b/contactsbackup/src/main/AndroidManifest.xml
index 4a6a2d6..fb8099a 100644
--- a/contactsbackup/src/main/AndroidManifest.xml
+++ b/contactsbackup/src/main/AndroidManifest.xml
@@ -6,8 +6,8 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:tools="http://schemas.android.com/tools"
     package="org.calyxos.backup.contacts"
-    android:versionCode="34030030"
-    android:versionName="14-3.3">
+    android:versionCode="34040000"
+    android:versionName="14-4.0">
     <!--
     The version code is the targeted SDK_VERSION plus 6 digits for our own version code.
     The version name is the targeted Android version followed by - and our own version name.
diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle
deleted file mode 100644
index cbfa11a..0000000
--- a/gradle/dependencies.gradle
+++ /dev/null
@@ -1,113 +0,0 @@
-ext {
-    // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-13.0.0_r3/current/androidx/Android.bp#3901
-    ext.room_version = "2.4.0-alpha05" // 2.5.0-alpha01 in AOSP but needs testing
-    // https://android.googlesource.com/platform/external/protobuf/+/refs/tags/android-14.0.0_r1/java/pom.xml#7
-    ext.protobuf_version = "3.21.7"
-
-    // test dependencies below - these do not care about AOSP and can be freely updated
-    junit4_version = "4.13.2"
-    junit5_version = "5.10.0" // careful, upgrading this can change a Cipher's IV size in tests!?
-    mockk_version = "1.13.4" // newer versions require kotlin > 1.8.10
-    espresso_version = "3.4.0"
-}
-
-// To produce these binaries, in latest AOSP source tree, run
-// $ m
-ext.aosp_libs = fileTree(include: [
-    // For more information about this module:
-    // https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-11.0.0_r3/Android.bp#507
-    // framework_intermediates/classes-header.jar works for gradle build as well,
-    // but not unit tests, so we use the actual classes (without updatable modules).
-    //
-    // out/target/common/obj/JAVA_LIBRARIES/framework-minus-apex_intermediates/classes.jar
-    'android.jar',
-    // out/target/common/obj/JAVA_LIBRARIES/core-libart.com.android.art_intermediates/classes.jar
-    'libcore.jar',
-], dir: "${rootProject.projectDir}/app/libs")
-
-ext.kotlin_libs = [
-    std: [
-        dependencies.create('org.jetbrains.kotlin:kotlin-stdlib') {
-            version { strictly "$aosp_kotlin_version" }
-        },
-        dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-jdk8') {
-            version { strictly "$aosp_kotlin_version" }
-        },
-        dependencies.create('org.jetbrains.kotlin:kotlin-stdlib-common') {
-            version { strictly "$aosp_kotlin_version" }
-        },
-    ],
-    coroutines: [
-        dependencies.create('org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm') {
-            // https://android.googlesource.com/platform/external/kotlinx.coroutines/+/refs/tags/android-14.0.0_r1/CHANGES.md
-            version { strictly '1.6.4' }
-        },
-        dependencies.create('org.jetbrains.kotlinx:kotlinx-coroutines-android') {
-            // https://android.googlesource.com/platform/external/kotlinx.coroutines/+/refs/tags/android-14.0.0_r1/CHANGES.md
-            version { strictly '1.6.4' }
-        },
-    ],
-]
-
-ext.std_libs = [
-    androidx_core: [
-        // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r1/current/androidx/Android.bp#2420
-        dependencies.create('androidx.core:core') {
-            version { strictly '1.9.0-alpha05' } // 1.9.0-alpha03 in AOSP but has SDK version issues
-        },
-        // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r1/current/androidx/Android.bp#2386
-        dependencies.create('androidx.core:core-ktx') {
-            version { strictly '1.9.0-alpha05' } // 1.9.0-alpha03 in AOSP but has SDK version issues
-        },
-    ],
-    // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r1/current/androidx/Android.bp#2849
-    androidx_fragment: dependencies.create('androidx.fragment:fragment-ktx') {
-        version { strictly '1.5.0-alpha03' }
-    },
-    // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r1/current/androidx/Android.bp#61
-    androidx_activity: dependencies.create('androidx.activity:activity-ktx') {
-        version { strictly '1.5.0-alpha03' }
-    },
-    // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r1/current/androidx/Android.bp#4420
-    androidx_preference: dependencies.create('androidx.preference:preference') {
-        version { strictly '1.2.0-alpha01' }
-    },
-    // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-13.0.0_r3/current/androidx/Android.bp#3521
-    androidx_lifecycle_viewmodel_ktx: dependencies.create('androidx.lifecycle:lifecycle-viewmodel-ktx') {
-        version { strictly '2.5.0-alpha03' }
-    },
-    // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r1/current/androidx/Android.bp#3279
-    androidx_lifecycle_livedata_ktx: dependencies.create('androidx.lifecycle:lifecycle-livedata-ktx') {
-        version { strictly '2.5.0-alpha03' }
-    },
-    // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r1/current/androidx/Android.bp#2244
-    androidx_constraintlayout: dependencies.create('androidx.constraintlayout:constraintlayout') {
-        version { strictly '2.2.0-alpha05' }
-    },
-    // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r1/current/androidx/Android.bp#2556
-    androidx_documentfile: dependencies.create('androidx.documentfile:documentfile') {
-        version { strictly '1.1.0-alpha01' } // 1.1.0-alpha02 in AOSP but not released yet
-    },
-    // https://android.googlesource.com/platform/prebuilts/sdk/+/refs/tags/android-14.0.0_r1/current/extras/material-design-x/Android.bp#15
-    com_google_android_material: dependencies.create('com.google.android.material:material') {
-        version { strictly '1.7.0-alpha03' }
-    },
-]
-
-ext.lint_libs = [
-    exceptions: 'com.github.thirdegg:lint-rules:0.1.0'
-]
-
-ext.storage_libs = [
-    androidx_room_runtime: dependencies.create('androidx.room:room-runtime') {
-        version { strictly "$room_version" }
-    },
-    com_google_protobuf_javalite: dependencies.create('com.google.protobuf:protobuf-javalite') {
-        version { strictly "$protobuf_version" }
-    },
-    // https://github.com/google/tink/releases
-    com_google_crypto_tink_android: dependencies.create('com.google.crypto.tink:tink-android') {
-        // careful with upgrading tink, so old backups continue to be decryptable
-        version { strictly '1.10.0' }
-    },
-]
diff --git a/gradle/ktlint.gradle b/gradle/ktlint.gradle
deleted file mode 100644
index 10a7cb0..0000000
--- a/gradle/ktlint.gradle
+++ /dev/null
@@ -1,11 +0,0 @@
-ktlint {
-    version = "0.42.1"
-    android = true
-    enableExperimentalRules = false
-    verbose = true
-    disabledRules = [
-            "import-ordering",
-            "no-blank-line-before-rbrace",
-            "indent",
-    ]
-}
diff --git a/settings.gradle b/settings.gradle
deleted file mode 100644
index 8725f6c..0000000
--- a/settings.gradle
+++ /dev/null
@@ -1,32 +0,0 @@
-pluginManagement {
-    buildscript {
-        repositories {
-            mavenCentral()
-            maven { url = uri("https://storage.googleapis.com/r8-releases/raw") }
-        }
-        dependencies {
-            // https://issuetracker.google.com/issues/227160052#comment37
-            // This can be removed when we switch to Android Gradle plugin 8.2.
-            classpath("com.android.tools:r8:8.2.28")
-        }
-    }
-
-    repositories {
-        gradlePluginPortal()
-        google()
-        mavenCentral()
-    }
-}
-dependencyResolutionManagement {
-    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
-    repositories {
-        google()
-        mavenCentral()
-        maven { url 'https://jitpack.io' }
-    }
-}
-rootProject.name = 'Seedvault'
-include ':app'
-include ':contactsbackup'
-include ':storage:lib'
-include ':storage:demo'
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..93105a8
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,44 @@
+pluginManagement {
+    buildscript {
+        repositories {
+            mavenCentral()
+            maven {
+                // https://issuetracker.google.com/issues/227160052#comment37
+                // This can be removed when we switch to Android Gradle plugin 8.2.
+                setUrl(uri("https://storage.googleapis.com/r8-releases/raw"))
+            }
+        }
+        dependencies {
+            classpath("com.android.tools:r8:8.2.28")
+        }
+    }
+
+    repositories {
+        gradlePluginPortal()
+        google()
+        mavenCentral()
+    }
+}
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+        maven("https://jitpack.io")
+    }
+    versionCatalogs {
+        create("libs") {
+            from(files("build.libs.toml"))
+        }
+        create("plugins") {
+            from(files("build.plugins.toml"))
+        }
+    }
+}
+
+rootProject.name = "Seedvault"
+include(":app")
+include(":contactsbackup")
+include(":storage:lib")
+include(":storage:demo")
diff --git a/storage/demo/build.gradle b/storage/demo/build.gradle
deleted file mode 100644
index 130a49d..0000000
--- a/storage/demo/build.gradle
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2021 The Calyx Institute
- * SPDX-License-Identifier: Apache-2.0
- */
-
-plugins {
-    id 'com.android.application'
-    id 'com.google.protobuf'
-    id 'org.jetbrains.kotlin.android'
-    id 'org.jetbrains.kotlin.kapt'
-}
-
-android {
-    namespace 'de.grobox.storagebackuptester'
-    compileSdk rootProject.ext.compileSdk
-
-    defaultConfig {
-        applicationId "de.grobox.storagebackuptester"
-        minSdk rootProject.ext.minSdk
-        targetSdk rootProject.ext.targetSdk
-        versionCode 20
-        versionName "0.9.7"
-
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-        testInstrumentationRunnerArguments disableAnalytics: 'true'
-    }
-
-    buildTypes {
-        release {
-            minifyEnabled true
-            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
-        }
-    }
-    compileOptions {
-        sourceCompatibility = JavaVersion.VERSION_17
-        targetCompatibility = JavaVersion.VERSION_17
-    }
-    kotlinOptions {
-        jvmTarget = JavaVersion.VERSION_17.toString()
-        freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
-    }
-    lint {
-        disable "DialogFragmentCallbacksDetector",
-                "InvalidFragmentVersionForActivityResult"
-    }
-    packagingOptions {
-        jniLibs {
-            excludes += ['META-INF/services/kotlin*']
-        }
-        resources {
-            excludes += [
-                    'META-INF/*.kotlin_module',
-                    'META-INF/androidx.*.version',
-                    'META-INF/services/kotlin*',
-                    'kotlin/internal/internal.kotlin_builtins'
-            ]
-        }
-    }
-}
-
-dependencies {
-    implementation project(':storage:lib')
-
-    implementation rootProject.ext.kotlin_libs.std
-
-    implementation rootProject.ext.std_libs.androidx_core
-    // A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
-    implementation rootProject.ext.std_libs.androidx_fragment
-    implementation rootProject.ext.std_libs.androidx_activity
-    implementation rootProject.ext.std_libs.androidx_lifecycle_viewmodel_ktx
-    implementation rootProject.ext.std_libs.androidx_lifecycle_livedata_ktx
-    implementation rootProject.ext.std_libs.androidx_constraintlayout
-    implementation rootProject.ext.std_libs.com_google_android_material
-
-    implementation rootProject.ext.storage_libs.com_google_protobuf_javalite
-}
diff --git a/storage/demo/build.gradle.kts b/storage/demo/build.gradle.kts
new file mode 100644
index 0000000..0f59892
--- /dev/null
+++ b/storage/demo/build.gradle.kts
@@ -0,0 +1,86 @@
+/*
+ * SPDX-FileCopyrightText: 2021 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+plugins {
+    id("com.android.application")
+    id("com.google.protobuf")
+    id("org.jetbrains.kotlin.android")
+    id("org.jetbrains.kotlin.kapt")
+}
+
+android {
+    namespace = "de.grobox.storagebackuptester"
+    compileSdk = libs.versions.compileSdk.get().toInt()
+
+    defaultConfig {
+        applicationId = "de.grobox.storagebackuptester"
+        minSdk = libs.versions.minSdk.get().toInt()
+        targetSdk = libs.versions.targetSdk.get().toInt()
+        versionCode = 20
+        versionName = "0.9.7"
+
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        testInstrumentationRunnerArguments.clear()
+        testInstrumentationRunnerArguments.putAll(mapOf("disableAnalytics" to "true"))
+    }
+
+    buildTypes {
+        getByName("release") {
+            isMinifyEnabled = true
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.toString()
+        freeCompilerArgs += listOf("-opt-in=kotlin.RequiresOptIn")
+    }
+
+    lint {
+        disable += setOf(
+            "DialogFragmentCallbacksDetector",
+            "InvalidFragmentVersionForActivityResult"
+        )
+    }
+
+    packaging {
+        jniLibs {
+            excludes += listOf("META-INF/services/kotlin*")
+        }
+        resources {
+            excludes += listOf(
+                "META-INF/*.kotlin_module",
+                "META-INF/androidx.*.version",
+                "META-INF/services/kotlin*",
+                "kotlin/internal/internal.kotlin_builtins"
+            )
+        }
+    }
+}
+
+dependencies {
+    implementation(project(":storage:lib"))
+
+    implementation(libs.bundles.kotlin)
+
+    implementation(libs.androidx.core)
+    // A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
+    implementation(libs.androidx.fragment)
+    implementation(libs.androidx.activity)
+    implementation(libs.androidx.lifecycle.viewmodel.ktx)
+    implementation(libs.androidx.lifecycle.livedata.ktx)
+    implementation(libs.androidx.constraintlayout)
+    implementation(libs.google.material)
+
+    implementation(libs.google.protobuf.javalite)
+}
diff --git a/storage/lib/build.gradle b/storage/lib/build.gradle
deleted file mode 100644
index c3bd1c9..0000000
--- a/storage/lib/build.gradle
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2021 The Calyx Institute
- * SPDX-License-Identifier: Apache-2.0
- */
-
-plugins {
-    id 'com.android.library'
-    id 'com.google.protobuf'
-    id 'org.jetbrains.kotlin.android'
-    id 'org.jetbrains.kotlin.kapt'
-    id 'org.jetbrains.dokka'
-    id 'org.jlleitschuh.gradle.ktlint'
-}
-
-android {
-    namespace 'org.calyxos.backup.storage'
-    compileSdk rootProject.ext.compileSdk
-
-    defaultConfig {
-        minSdk rootProject.ext.minSdk
-        targetSdk rootProject.ext.targetSdk
-
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-        testInstrumentationRunnerArguments disableAnalytics: 'true'
-
-        consumerProguardFiles "consumer-rules.pro"
-    }
-
-    buildTypes {
-        all {
-            minifyEnabled true
-            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
-        }
-    }
-    compileOptions {
-        sourceCompatibility = JavaVersion.VERSION_17
-        targetCompatibility = JavaVersion.VERSION_17
-    }
-    kotlinOptions {
-        jvmTarget = JavaVersion.VERSION_17.toString()
-        languageVersion = "1.8"
-        freeCompilerArgs += '-opt-in=kotlin.RequiresOptIn'
-        freeCompilerArgs += '-Xexplicit-api=strict'
-    }
-    protobuf {
-        protoc {
-            if ("aarch64" == System.getProperty("os.arch")) {
-                // mac m1
-                artifact = "com.google.protobuf:protoc:$protobuf_version:osx-x86_64"
-            } else {
-                // other
-                artifact = "com.google.protobuf:protoc:$protobuf_version"
-            }
-        }
-        generateProtoTasks {
-            all().each { task ->
-                task.builtins {
-                    java {
-                        option "lite"
-                    }
-                }
-            }
-        }
-    }
-    lint {
-        disable "DialogFragmentCallbacksDetector",
-                "InvalidFragmentVersionForActivityResult",
-                "CheckedExceptions"
-    }
-}
-
-kotlin {
-    explicitApi = 'strict'
-}
-
-dependencies {
-    implementation rootProject.ext.kotlin_libs.std
-
-    implementation rootProject.ext.std_libs.androidx_core
-    // A newer version gets pulled in with AOSP via core, so we include fragment here explicitly
-    implementation rootProject.ext.std_libs.androidx_fragment
-    implementation rootProject.ext.std_libs.androidx_activity
-    implementation rootProject.ext.std_libs.androidx_lifecycle_viewmodel_ktx
-    implementation rootProject.ext.std_libs.androidx_lifecycle_livedata_ktx
-    implementation rootProject.ext.std_libs.androidx_constraintlayout
-    implementation rootProject.ext.std_libs.androidx_documentfile
-    implementation rootProject.ext.std_libs.com_google_android_material
-
-    implementation rootProject.ext.storage_libs.androidx_room_runtime
-    implementation rootProject.ext.storage_libs.com_google_protobuf_javalite
-    implementation rootProject.ext.storage_libs.com_google_crypto_tink_android
-    kapt('androidx.room:room-compiler') {
-        version { strictly "$room_version" }
-    }
-
-    lintChecks rootProject.ext.lint_libs.exceptions
-
-    testImplementation "junit:junit:$junit4_version"
-    testImplementation "io.mockk:mockk:$mockk_version"
-    testImplementation "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version"
-
-    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
-    androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version"
-}
-
-apply from: "${rootProject.rootDir}/gradle/ktlint.gradle"
diff --git a/storage/lib/build.gradle.kts b/storage/lib/build.gradle.kts
new file mode 100644
index 0000000..3cd72ba
--- /dev/null
+++ b/storage/lib/build.gradle.kts
@@ -0,0 +1,106 @@
+import com.google.protobuf.gradle.id
+
+/*
+ * SPDX-FileCopyrightText: 2021 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+plugins {
+    id("com.google.protobuf")
+    id("org.jetbrains.kotlin.kapt")
+    id("org.jetbrains.dokka")
+    id("com.android.library")
+    kotlin("android")
+}
+
+android {
+    namespace = "org.calyxos.backup.storage"
+    compileSdk = libs.versions.compileSdk.get().toInt()
+
+    defaultConfig {
+        minSdk = libs.versions.minSdk.get().toInt()
+        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+        testInstrumentationRunnerArguments["disableAnalytics"] = "true"
+    }
+
+    buildTypes {
+        all {
+            isMinifyEnabled = true
+            proguardFiles(
+                getDefaultProguardFile("proguard-android-optimize.txt"),
+                "proguard-rules.pro"
+            )
+        }
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = JavaVersion.VERSION_17.toString()
+        languageVersion = "1.8"
+        freeCompilerArgs += listOf(
+            "-opt-in=kotlin.RequiresOptIn",
+            "-Xexplicit-api=strict"
+        )
+    }
+
+    protobuf {
+        protoc {
+            if ("aarch64" == System.getProperty("os.arch")) {
+                // mac m1
+                artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}:osx-x86_64"
+            } else {
+                // other
+                artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}"
+            }
+        }
+        generateProtoTasks {
+            all().forEach { task ->
+                task.builtins {
+                    id("java") {
+                        option("lite")
+                    }
+                }
+            }
+        }
+    }
+
+    lint {
+        checkReleaseBuilds = false
+        abortOnError = false
+
+        disable.clear()
+        disable += setOf(
+            "DialogFragmentCallbacksDetector",
+            "InvalidFragmentVersionForActivityResult",
+            "CheckedExceptions"
+        )
+    }
+}
+
+dependencies {
+    implementation(libs.bundles.kotlin)
+    implementation(libs.androidx.core)
+    implementation(libs.androidx.fragment)
+    implementation(libs.androidx.activity)
+    implementation(libs.androidx.lifecycle.viewmodel.ktx)
+    implementation(libs.androidx.lifecycle.livedata.ktx)
+    implementation(libs.androidx.constraintlayout)
+    implementation(libs.androidx.documentfile)
+    implementation(libs.google.material)
+    implementation(libs.androidx.room.runtime)
+    implementation(libs.google.protobuf.javalite)
+    implementation(libs.google.tink.android)
+
+    kapt(group = "androidx.room", name = "room-compiler", version = libs.versions.room.get())
+    lintChecks(libs.thirdegg.lint.rules)
+    testImplementation("junit:junit:${libs.versions.junit4.get()}")
+    testImplementation("io.mockk:mockk:${libs.versions.mockk.get()}")
+    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${libs.versions.aosp.kotlin.get()}")
+    androidTestImplementation("androidx.test.ext:junit:1.1.5")
+    androidTestImplementation(
+        "androidx.test.espresso:espresso-core:${libs.versions.espresso.get()}"
+    )
+}
diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt
index e08d538..d1451f2 100644
--- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt
+++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/Backup.kt
@@ -22,7 +22,6 @@
 import java.io.IOException
 import java.security.GeneralSecurityException
 import kotlin.time.Duration
-import kotlin.time.ExperimentalTime
 
 internal class BackupResult(
     val chunkIds: Set<String>,
@@ -86,7 +85,6 @@
     )
 
     @Throws(IOException::class, GeneralSecurityException::class)
-    @OptIn(ExperimentalTime::class)
     suspend fun runBackup(backupObserver: BackupObserver?) {
         backupObserver?.onStartScanning()
         var duration: Duration? = null
@@ -121,7 +119,6 @@
     }
 
     @Throws(IOException::class, GeneralSecurityException::class)
-    @OptIn(ExperimentalTime::class)
     private suspend fun backupFiles(
         filesResult: FileScannerResult,
         availableChunkIds: HashSet<String>,
diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt
index 3979f1b..606e879 100644
--- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt
+++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ChunkWriter.kt
@@ -17,7 +17,6 @@
 import java.nio.file.attribute.FileTime
 import java.security.GeneralSecurityException
 import java.util.zip.ZipEntry
-import java.util.zip.ZipOutputStream
 import kotlin.math.min
 
 internal data class ChunkWriterResult(
@@ -121,9 +120,17 @@
     }
 
     @Throws(IOException::class)
-    fun writeNewZipEntry(zipOutputStream: ZipOutputStream, counter: Int, inputStream: InputStream) {
-        val entry = createNewZipEntry(counter)
-        zipOutputStream.putNextEntry(entry)
+    fun writeNewZipEntry(
+        zipOutputStream: NameZipOutputStream,
+        counter: Int,
+        inputStream: InputStream,
+    ) {
+        // If copying below throws an exception, we'd be adding a new entry with the same counter,
+        // so we check if we have added an entry for that counter already to prevent duplicates.
+        if ((zipOutputStream.lastName?.toIntOrNull() ?: 0) != counter) {
+            val entry = createNewZipEntry(counter)
+            zipOutputStream.putNextEntry(entry)
+        }
         inputStream.copyTo(zipOutputStream)
         zipOutputStream.closeEntry()
     }
diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt
index f98f932..88efa1b 100644
--- a/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt
+++ b/storage/lib/src/main/java/org/calyxos/backup/storage/backup/ZipChunker.kt
@@ -11,7 +11,9 @@
 import java.io.ByteArrayOutputStream
 import java.io.IOException
 import java.io.InputStream
+import java.io.OutputStream
 import java.security.GeneralSecurityException
+import java.util.zip.ZipEntry
 import java.util.zip.ZipOutputStream
 import javax.crypto.Mac
 
@@ -36,7 +38,7 @@
     private val files = ArrayList<ContentFile>()
 
     private val outputStream = ByteArrayOutputStream(chunkSizeMax)
-    private var zipOutputStream = ZipOutputStream(outputStream)
+    private var zipOutputStream = NameZipOutputStream(outputStream)
 
     // we start with 1, because 0 is the default value in protobuf 3
     private var counter = 1
@@ -77,8 +79,21 @@
     private fun reset() {
         files.clear()
         outputStream.reset()
-        zipOutputStream = ZipOutputStream(outputStream)
+        zipOutputStream = NameZipOutputStream(outputStream)
         counter = 1
     }
 
 }
+
+/**
+ * A wrapper for [ZipOutputStream] that remembers the name of the last [ZipEntry] that was added.
+ */
+internal class NameZipOutputStream(outputStream: OutputStream) : ZipOutputStream(outputStream) {
+    internal var lastName: String? = null
+        private set
+
+    override fun putNextEntry(e: ZipEntry) {
+        super.putNextEntry(e)
+        lastName = e.name
+    }
+}
diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt
index 801e1cf..1677613 100644
--- a/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt
+++ b/storage/lib/src/main/java/org/calyxos/backup/storage/restore/Restore.kt
@@ -26,7 +26,6 @@
 
 private const val TAG = "Restore"
 
-@Suppress("BlockingMethodInNonBlockingContext")
 internal class Restore(
     context: Context,
     private val storagePlugin: StoragePlugin,
@@ -57,14 +56,15 @@
         MultiChunkRestore(context, storagePlugin, fileRestore, streamCrypto, streamKey)
     }
 
-    @OptIn(ExperimentalTime::class)
     fun getBackupSnapshots(): Flow<SnapshotResult> = flow {
         val numSnapshots: Int
         val time = measure {
             val list = try {
+                // get all available backups, they may not be usable
                 storagePlugin.getBackupSnapshotsForRestore().sortedByDescending { storedSnapshot ->
                     storedSnapshot.timestamp
                 }.map { storedSnapshot ->
+                    // as long as snapshot is null, it can't be used for restore
                     SnapshotItem(storedSnapshot, null)
                 }.toMutableList()
             } catch (e: Exception) {
@@ -74,6 +74,7 @@
             }
             // return list copies, so this works with ListAdapter and DiffUtils
             emit(SnapshotResult.Success(ArrayList(list)))
+            // try to decrypt snapshots and replace list items, if we can decrypt, otherwise remove
             numSnapshots = list.size
             val iterator = list.listIterator()
             while (iterator.hasNext()) {
@@ -153,13 +154,13 @@
 @Throws(IOException::class, GeneralSecurityException::class)
 internal fun InputStream.readVersion(expectedVersion: Int? = null): Int {
     val version = read()
-    if (version == -1) throw IOException()
+    if (version == -1) throw IOException("File empty!")
     if (expectedVersion != null && version != expectedVersion) {
         throw GeneralSecurityException("Expected version $expectedVersion, not $version")
     }
     if (version > Backup.VERSION) {
         // TODO maybe throw a different exception here and tell the user?
-        throw IOException()
+        throw IOException("Got version $version which is higher than what is supported.")
     }
     return version
 }
diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/scanner/MediaScanner.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/scanner/MediaScanner.kt
index 0e0c352..c2ece36 100644
--- a/storage/lib/src/main/java/org/calyxos/backup/storage/scanner/MediaScanner.kt
+++ b/storage/lib/src/main/java/org/calyxos/backup/storage/scanner/MediaScanner.kt
@@ -12,6 +12,7 @@
 import android.net.Uri
 import android.os.Bundle
 import android.os.Environment
+import android.provider.DocumentsContract.Document.MIME_TYPE_DIR
 import android.provider.MediaStore
 import android.provider.MediaStore.MediaColumns.IS_DOWNLOAD
 import android.util.Log
@@ -57,15 +58,18 @@
 
     internal fun scanMediaUri(uri: Uri, extraQuery: String? = null): List<MediaFile> {
         val extras = Bundle().apply {
-            val query = StringBuilder()
+            val query = StringBuilder().apply {
+                // don't include directories (if they are non-empty they will be in implicitly)
+                append("${MediaStore.MediaColumns.MIME_TYPE}!='$MIME_TYPE_DIR'")
+            }
             if (uri != MediaType.Downloads.contentUri) {
-                query.append("$IS_DOWNLOAD=0")
+                query.append(" AND $IS_DOWNLOAD=0")
             }
             extraQuery?.let {
-                if (query.isNotEmpty()) query.append(" AND ")
+                query.append(" AND ")
                 query.append(it)
             }
-            if (query.isNotEmpty()) putString(QUERY_ARG_SQL_SELECTION, query.toString())
+            putString(QUERY_ARG_SQL_SELECTION, query.toString())
         }
         val cursor = contentResolver.query(uri, PROJECTION, extras, null)
         return ArrayList<MediaFile>(cursor?.count ?: 0).apply {
@@ -106,7 +110,6 @@
     }
 
     private fun getRealSize(mediaFile: MediaFile): Long {
-        @Suppress("DEPRECATION")
         val extDir = Environment.getExternalStorageDirectory()
         val path = "$extDir/${mediaFile.dirPath}/${mediaFile.fileName}"
         return try {
diff --git a/storage/lib/src/main/res/values-ru/strings.xml b/storage/lib/src/main/res/values-ru/strings.xml
index f2ef4b9..c0227ed 100644
--- a/storage/lib/src/main/res/values-ru/strings.xml
+++ b/storage/lib/src/main/res/values-ru/strings.xml
@@ -8,7 +8,7 @@
 \n
 \nИзвините, но нет ничего, что можно было бы восстановить.</string>
     <string name="snapshots_title">Доступные резервные копии хранилища</string>
-    <string name="content_images">Фотографии и изображения</string>
+    <string name="content_images">Фото и изображения</string>
     <string name="content_videos">Видео</string>
     <string name="content_audio">Аудиофайлы</string>
     <string name="notification_backup_title">Резервное копирование хранилища</string>
@@ -17,6 +17,6 @@
     <string name="notification_prune">Удаление старых резервных копий…</string>
     <string name="notification_restore_channel_title">Восстановление хранилища</string>
     <string name="notification_restore_info">%1$d/%2$d</string>
-    <string name="snapshots_error">Ошибка загрузки снимков</string>
+    <string name="snapshots_error">Ошибка при загрузке снимков хранилища</string>
     <string name="content_options">Опции</string>
 </resources>
\ No newline at end of file
diff --git a/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt
new file mode 100644
index 0000000..4d8214e
--- /dev/null
+++ b/storage/lib/src/test/java/org/calyxos/backup/storage/backup/SmallFileBackupIntegrationTest.kt
@@ -0,0 +1,115 @@
+/*
+ * SPDX-FileCopyrightText: 2021 The Calyx Institute
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.calyxos.backup.storage.backup
+
+import android.content.ContentResolver
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import kotlinx.coroutines.runBlocking
+import org.calyxos.backup.storage.api.BackupObserver
+import org.calyxos.backup.storage.api.StoragePlugin
+import org.calyxos.backup.storage.crypto.Hkdf.KEY_SIZE_BYTES
+import org.calyxos.backup.storage.crypto.StreamCrypto
+import org.calyxos.backup.storage.db.CachedChunk
+import org.calyxos.backup.storage.db.ChunksCache
+import org.calyxos.backup.storage.db.FilesCache
+import org.calyxos.backup.storage.getRandomDocFile
+import org.calyxos.backup.storage.getRandomString
+import org.calyxos.backup.storage.mockLog
+import org.calyxos.backup.storage.toHexString
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+import java.io.InputStream
+import javax.crypto.Mac
+import kotlin.random.Random
+
+internal class SmallFileBackupIntegrationTest {
+
+    private val contentResolver: ContentResolver = mockk()
+    private val filesCache: FilesCache = mockk()
+    private val mac: Mac = mockk()
+    private val chunksCache: ChunksCache = mockk()
+    private val storagePlugin: StoragePlugin = mockk()
+
+    private val chunkWriter = ChunkWriter(
+        streamCrypto = StreamCrypto,
+        streamKey = Random.nextBytes(KEY_SIZE_BYTES),
+        chunksCache = chunksCache,
+        storagePlugin = storagePlugin,
+    )
+    private val zipChunker = ZipChunker(
+        mac = mac,
+        chunkWriter = chunkWriter,
+    )
+
+    private val smallFileBackup = SmallFileBackup(contentResolver, filesCache, zipChunker, true)
+
+    init {
+        mockLog()
+    }
+
+    /**
+     * This tests that if writing out one ZIP entry throws an exception,
+     * the subsequent entries can still be written.
+     * Previously, we'd start a new ZipEntry with the same counter value
+     * which is not allowed, so all subsequent files would also not get backed up.
+     */
+    @Test
+    fun `first of two new files throws and gets ignored`(): Unit = runBlocking {
+        val file1 = getRandomDocFile()
+        val file2 = getRandomDocFile()
+        val files = listOf(file1, file2)
+        val availableChunkIds = hashSetOf(getRandomString(6))
+        val observer: BackupObserver = mockk()
+
+        val inputStream1: InputStream = mockk()
+        val inputStream2: InputStream = ByteArrayInputStream(Random.nextBytes(42))
+        val outputStream2 = ByteArrayOutputStream()
+
+        val chunkId = Random.nextBytes(KEY_SIZE_BYTES)
+        val cachedChunk = CachedChunk(chunkId.toHexString(), 0, 181, 0)
+        val cachedFile2 = file2.toCachedFile(listOf(chunkId.toHexString()), 1)
+        val backupFile = file2.toBackupFile(cachedFile2.chunks, cachedFile2.zipIndex)
+
+        every { filesCache.getByUri(file1.uri) } returns null
+        every { filesCache.getByUri(file2.uri) } returns null
+
+        every { contentResolver.openInputStream(file1.uri) } returns inputStream1
+        every { contentResolver.openInputStream(file2.uri) } returns inputStream2
+
+        every { inputStream1.read(any<ByteArray>()) } throws IOException()
+        coEvery { observer.onFileBackupError(file1, "S") } just Runs
+
+        every { mac.doFinal(any<ByteArray>()) } returns chunkId
+        every { chunksCache.get(any()) } returns null
+        every { storagePlugin.getChunkOutputStream(any()) } returns outputStream2
+        every { chunksCache.insert(cachedChunk) } just Runs
+        every {
+            filesCache.upsert(match {
+                it.copy(lastSeen = cachedFile2.lastSeen) == cachedFile2
+            })
+        } just Runs
+        coEvery { observer.onFileBackedUp(file2, true, 0, 181, "S") } just Runs
+
+        val result = smallFileBackup.backupFiles(files, availableChunkIds, observer)
+        assertEquals(setOf(chunkId.toHexString()), result.chunkIds)
+        assertEquals(1, result.backupDocumentFiles.size)
+        assertEquals(backupFile, result.backupDocumentFiles[0])
+        assertEquals(0, result.backupMediaFiles.size)
+
+        coVerify {
+            observer.onFileBackedUp(file2, true, 0, 181, "S")
+        }
+    }
+
+}