Merge pull request #562 from seedvault-app/feature/d2d-transfer

Add experimental support for forcing D2D transfer backups
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..8aeddf7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,6 +20,7 @@
       matrix:
         android_target: [ 33, 34 ]
         emulator_type: [ default ]
+        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/app/build.gradle.kts b/app/build.gradle.kts
index cb2151c..52a0e18 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -24,14 +24,17 @@
         targetSdk = libs.versions.targetSdk.get().toInt()
         versionNameSuffix = "-${gitDescribe()}"
         testInstrumentationRunner = "com.stevesoltys.seedvault.KoinInstrumentationTestRunner"
-        testInstrumentationRunnerArguments(mapOf("disableAnalytics" to "true"))
+        testInstrumentationRunnerArguments["disableAnalytics"] = "true"
 
         if (project.hasProperty("instrumented_test_size")) {
             val testSize = project.property("instrumented_test_size").toString()
             println("Instrumented test size: $testSize")
 
-            testInstrumentationRunnerArguments(mapOf("size" to testSize))
+            testInstrumentationRunnerArguments["size"] = testSize
         }
+
+        val d2dBackupTest = project.findProperty("d2d_backup_test")?.toString() ?: "true"
+        testInstrumentationRunnerArguments["d2d_backup_test"] = d2dBackupTest
     }
 
     signingConfigs {
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..0886c68 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()
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/java/com/stevesoltys/seedvault/metadata/Metadata.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt
index c480833..c8ad6aa 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 {
     /**
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..f58a29a 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 = "")
@@ -135,6 +137,8 @@
         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
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..bbd6df1 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt
@@ -152,7 +152,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..bbed50c 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) {
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/settings/AppListRetriever.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt
index 29cb923..e185b79 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/AppListRetriever.kt
@@ -55,9 +55,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> {
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..c7e7d37 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,25 @@
             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 ->
+            d2dPreference.isChecked = newValue as Boolean
+
+            // 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/SettingsFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsFragment.kt
index 93ed088..38aaf80 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 {
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 a83c844..b7ab7c6 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt
@@ -16,6 +16,7 @@
 
 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) {
 
@@ -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(
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/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt
index 08c56f6..87a1302 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
     }
 
     /**
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/PackageService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt
index fbadc1a..7d16e17 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,6 +31,8 @@
 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
@@ -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)) {
@@ -66,6 +73,9 @@
             return packageArray.toTypedArray()
         }
 
+    /**
+     * A list of packages that will not be backed up.
+     */
     val notBackedUpPackages: List<PackageInfo>
         @WorkerThread
         get() {
@@ -94,16 +104,23 @@
     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
@@ -128,12 +145,64 @@
         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 {
+        if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == 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 {
+            applicationInfo.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
+        return !allowsBackup() || isStopped()
+    }
 }
 
 internal data class ExpectedAppTotals(
@@ -157,11 +226,6 @@
     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
-}
-
 /**
  * Returns true if this is a system app that hasn't been updated.
  * We don't back up those APKs.
@@ -173,12 +237,6 @@
     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
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/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 502ba39..d5daf94 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -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 experimental, use at your own risk.\n\n1. To backup apps in D2D mode, you will need to run \"Backup now\" manually.\n\n2. Android may overwrite D2D backups for apps which normally allow backups.</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>
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..1a41a02 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()
@@ -246,6 +263,23 @@
             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, storageOutputStream)
+        assertTrue(initialMetadata.d2dBackup)
 
         verify {
             cacheInputStream.close()
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/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt
index 5a7ece9..42c7348 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,8 +32,6 @@
 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
@@ -46,8 +44,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 +169,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
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