From 8ac75bbe0fd00fcc63cad37288e51cc777c6d2bc Mon Sep 17 00:00:00 2001 From: Steve Soltys Date: Tue, 2 Jan 2024 22:02:16 -0500 Subject: [PATCH] Add experimental support for forcing D2D transfer backups --- .github/scripts/run_tests.sh | 4 +- .github/workflows/test.yml | 3 +- app/build.gradle.kts | 7 +- app/development/scripts/provision_emulator.sh | 2 +- .../seedvault/e2e/LargeBackupTestBase.kt | 3 - .../seedvault/e2e/LargeRestoreTestBase.kt | 4 + .../seedvault/e2e/LargeTestBase.kt | 6 ++ .../seedvault/e2e/SeedvaultLargeTest.kt | 24 ++++- .../seedvault/e2e/SeedvaultLargeTestResult.kt | 4 +- .../java/com/stevesoltys/seedvault/App.kt | 4 - .../seedvault/metadata/Metadata.kt | 2 + .../seedvault/metadata/MetadataManager.kt | 4 + .../seedvault/metadata/MetadataModule.kt | 2 +- .../seedvault/metadata/MetadataReader.kt | 3 +- .../seedvault/metadata/MetadataWriter.kt | 1 + .../seedvault/restore/RestorableBackup.kt | 3 + .../seedvault/settings/AppListRetriever.kt | 13 ++- .../settings/ExpertSettingsFragment.kt | 10 +++ .../seedvault/settings/SettingsFragment.kt | 2 +- .../seedvault/settings/SettingsManager.kt | 10 +++ .../transport/ConfigurableBackupTransport.kt | 13 ++- .../transport/backup/PackageService.kt | 87 ++++++++++++------- .../transport/restore/RestoreCoordinator.kt | 29 ++++++- app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/settings_expert.xml | 6 ++ .../java/com/stevesoltys/seedvault/TestApp.kt | 2 + .../seedvault/metadata/MetadataManagerTest.kt | 17 +++- .../seedvault/transport/TransportTest.kt | 4 + .../transport/backup/BackupCoordinatorTest.kt | 28 +++++- .../restore/RestoreCoordinatorTest.kt | 11 ++- 30 files changed, 244 insertions(+), 66 deletions(-) diff --git a/.github/scripts/run_tests.sh b/.github/scripts/run_tests.sh index 48a86572f..b680a2472 100755 --- a/.github/scripts/run_tests.sh +++ b/.github/scripts/run_tests.sh @@ -14,8 +14,10 @@ echo "Setting Seedvault transport..." 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/test.yml b/.github/workflows/test.yml index a624c775a..8aeddf717 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,7 @@ jobs: 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 @@ jobs: 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 cb2151cf0..52a0e183d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,14 +24,17 @@ android { 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 fef04b8bb..284e70829 100755 --- a/app/development/scripts/provision_emulator.sh +++ b/app/development/scripts/provision_emulator.sh @@ -84,7 +84,7 @@ echo "Downloading and extracting test backup to '/sdcard/seedvault_baseline'..." 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/e2e/LargeBackupTestBase.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/e2e/LargeBackupTestBase.kt index 70f589740..0886c6824 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 @@ internal interface LargeBackupTestBase : LargeTestBase { 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 3d74aedea..be95877db 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 @@ internal interface LargeRestoreTestBase : LargeTestBase { coEvery { spyFullRestore.initializeState(any(), any(), any(), any()) } answers { + packageName?.let { + restoreResult.full[it] = dataIntercept.toByteArray().sha256() + } + packageName = arg(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 86f14a28a..69d0cf62d 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 @@ internal interface LargeTestBase : KoinComponent { val spyMetadataManager: MetadataManager get() = get() + val backupManager: IBackupManager get() = get() + val spyRestoreViewModel: RestoreViewModel get() = currentRestoreViewModel ?: error("currentRestoreViewModel is null") @@ -79,6 +82,7 @@ internal interface LargeTestBase : KoinComponent { get() = currentRestoreStorageViewModel ?: error("currentRestoreStorageViewModel is null") fun resetApplicationState() { + backupManager.setAutoRestore(false) settingsManager.setNewToken(null) documentsStorage.reset(null) @@ -95,6 +99,7 @@ internal interface LargeTestBase : KoinComponent { } clearDocumentPickerAppData() + device.executeShellCommand("rm -R $externalStorageDir/.SeedVaultAndroidBackup") } fun waitUntilIdle() { @@ -157,6 +162,7 @@ internal interface LargeTestBase : KoinComponent { 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 2d2be5f19..e2fa7d9fd 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 @@ package com.stevesoltys.seedvault.e2e 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 @@ internal abstract class SeedvaultLargeTest : 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 @@ internal abstract class SeedvaultLargeTest : 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 3223aa52a..4c5e3b6c0 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 @@ package com.stevesoltys.seedvault.e2e 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 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata * For full backups, the mapping is: Map * For K/V backups, the mapping is: Map> */ -data class SeedvaultLargeTestResult( +internal data class SeedvaultLargeTestResult( val backupResults: Map = emptyMap(), + val restoreResults: Map = emptyMap(), val full: MutableMap, val kv: MutableMap>, val userApps: List, diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index 3cdc3365f..f3403de79 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -9,7 +9,6 @@ import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build import android.os.ServiceManager.getService import android.os.StrictMode -import android.os.SystemProperties import android.os.UserManager import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.header.headerModule @@ -60,7 +59,6 @@ open class App : Application() { override fun onCreate() { super.onCreate() - SystemProperties.set(BACKUP_D2D_PROPERTY, "true") startKoin() if (isDebugBuild()) { StrictMode.setThreadPolicy( @@ -123,8 +121,6 @@ const val MAGIC_PACKAGE_MANAGER = PACKAGE_MANAGER_SENTINEL const val ANCESTRAL_RECORD_KEY = "@ancestral_record@" const val GLOBAL_METADATA_KEY = "@meta@" -const val BACKUP_D2D_PROPERTY = "persist.backup.fake-d2d" - // TODO this doesn't work for LineageOS as they do public debug builds fun isDebugBuild() = Build.TYPE == "userdebug" 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 c48083316..c8ad6aacc 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 @@ data class BackupMetadata( 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_TIME = "time" 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 98a57f4a0..f58a29a16 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.APK_AND_DATA 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 @@ internal class MetadataManager( 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 @@ internal class MetadataManager( 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 68c723a45..0d7ed1a2f 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.android.ext.koin.androidContext import org.koin.dsl.module val metadataModule = module { - single { MetadataManager(androidContext(), get(), get(), get(), get()) } + single { MetadataManager(androidContext(), get(), get(), get(), get(), get()) } single { MetadataWriterImpl(get()) } single { 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 382a1757c..bbd6df190 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 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader { 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 1359c112a..bbed50c73 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 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter { 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 f1c33c16c..2c3abb3c0 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 @@ data class RestorableBackup(val backupMetadata: BackupMetadata) { 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 29cb923cc..e185b79bf 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 @@ internal class AppListRetriever( @WorkerThread fun getAppList(): List { - 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 { 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 f4a2effd1..05607375f 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 android.os.Bundle 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 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() { 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 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() { permitDiskReads { setPreferencesFromResource(R.xml.settings_expert, rootKey) } + findPreference("logcat")?.setOnPreferenceClickListener { val versionName = packageService.getVersionName(requireContext().packageName) ?: "ver" val timestamp = System.currentTimeMillis() @@ -30,6 +33,13 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() { createFileLauncher.launch(name) true } + + val d2dPreference = findPreference(PREF_KEY_D2D_BACKUPS) + + d2dPreference?.setOnPreferenceChangeListener { _, newValue -> + d2dPreference.isChecked = newValue as Boolean + 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 93ed08836..38aaf805e 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 @@ class SettingsFragment : PreferenceFragmentCompat() { 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 a83c844fc..2647e57b7 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 @@ 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" @@ -31,6 +32,7 @@ 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_D2D_BACKUPS = "d2d_backups" class SettingsManager(private val context: Context) { @@ -151,6 +153,14 @@ class SettingsManager(private val context: Context) { } 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 a90b14b00..cead1eabf 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/ConfigurableBackupTransport.kt @@ -12,6 +12,7 @@ import android.os.ParcelFileDescriptor 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 @@ -21,9 +22,8 @@ import org.koin.core.component.inject // If we ever change this, we should use a ComponentName like the other backup transports. val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name -// Since there seems to be consensus in the community to pose as device-to-device transport, -// we are pretending to be one here. This will back up opt-out apps that target at least API 31. -const val TRANSPORT_FLAGS = FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED or FLAG_DEVICE_TO_DEVICE_TRANSFER +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" @@ -38,6 +38,7 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont private val backupCoordinator by inject() private val restoreCoordinator by inject() + private val settingsManager by inject() override fun transportDirName(): String { return TRANSPORT_DIRECTORY_NAME @@ -57,7 +58,11 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont * 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/PackageService.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/PackageService.kt index d298856e1..58ee64316 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 @@ -48,13 +48,7 @@ internal class PackageService( logPackages(packages) } - // We do not use BackupManager.filterAppsEligibleForBackupForUser 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. - val eligibleApps = packages.filter(::shouldIncludeAppInBackup) + val eligibleApps = packages.filter(::shouldIncludeAppInBackup).toTypedArray() // log eligible packages if (Log.isLoggable(TAG, INFO)) { @@ -69,6 +63,9 @@ internal class PackageService( return packageArray.toTypedArray() } + /** + * A list of packages that will not be backed up. + */ val notBackedUpPackages: List @WorkerThread get() { @@ -97,7 +94,8 @@ internal class PackageService( val userApps: List @WorkerThread get() = packageManager.getInstalledPackages(GET_INSTRUMENTATION).filter { packageInfo -> - packageInfo.isUserVisible(context) && packageInfo.allowsBackup() + packageInfo.isUserVisible(context) && + packageInfo.allowsBackup() } /** @@ -106,7 +104,8 @@ internal class PackageService( val userNotAllowedApps: List @WorkerThread get() = packageManager.getInstalledPackages(0).filter { packageInfo -> - !packageInfo.allowsBackup() && !packageInfo.isSystemApp() + !packageInfo.allowsBackup() && + !packageInfo.isSystemApp() } val expectedAppTotals: ExpectedAppTotals @@ -132,11 +131,24 @@ internal class PackageService( } 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 also need to exclude the DocumentsProvider used to store backup data. - // Otherwise, it gets killed when we back it up, terminating our backup. - return enabled && packageName != plugin.providerPackageName + + // 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) { @@ -145,6 +157,37 @@ internal class PackageService( } } + 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( @@ -168,20 +211,6 @@ internal fun PackageInfo.isSystemApp(): Boolean { return applicationInfo.flags and FLAG_SYSTEM != 0 } -internal fun PackageInfo.allowsBackup(): Boolean { - if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false - - // 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. - // Now that we have switched to 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). - return true -} - /** * Returns true if this is a system app that hasn't been updated. * We don't back up those APKs. @@ -193,12 +222,6 @@ internal fun PackageInfo.isNotUpdatedSystemApp(): Boolean { 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 f6a00ecc6..684312582 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,7 +22,8 @@ import com.stevesoltys.seedvault.metadata.MetadataManager 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 @@ -32,7 +33,7 @@ import java.io.IOException * 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 DEVICE_NAME_FOR_D2D_SET = "D2D" +internal const val D2D_DEVICE_NAME = "D2D" private data class RestoreCoordinatorState( val token: Long, @@ -100,8 +101,20 @@ internal class RestoreCoordinator( **/ suspend fun getAvailableRestoreSets(): Array? { return getAvailableMetadata()?.map { (_, metadata) -> - RestoreSet(metadata.deviceName /* name */, DEVICE_NAME_FOR_D2D_SET /* device */, - 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() } @@ -123,6 +136,10 @@ internal class RestoreCoordinator( */ fun beforeStartRestore(backupMetadata: BackupMetadata) { this.backupMetadata = backupMetadata + + if (backupMetadata.d2dBackup) { + settingsManager.setD2dBackupsEnabled(true) + } } /** @@ -228,6 +245,7 @@ internal class RestoreCoordinator( 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)) { @@ -237,6 +255,7 @@ internal class RestoreCoordinator( 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 -> @@ -270,12 +289,14 @@ internal class RestoreCoordinator( 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 502ba39c1..18f8326f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,8 @@ Expert settings Unlimited app quota 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. + Device-to-device backups + Tell AOSP that our backups are being used for a D2D transfer. This forces backups for most apps, even when they disallow them.\n\nWarning: This is experimental, use at your own risk.\n\nSee more info on our FAQ. Save app log Developers can diagnose bugs with these logs.\n\nWarning: The log file might contain personally identifiable information. Review before and delete after sharing! Error: Could not save app log diff --git a/app/src/main/res/xml/settings_expert.xml b/app/src/main/res/xml/settings_expert.xml index 11e8497c6..0125bf4e7 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" /> + () private val full = mockk() private val apkBackup = mockk() - private val packageService: PackageService = mockk() private val notificationManager = mockk() + private val packageService = PackageService( + context, + settingsManager, + plugin + ) + private val backup = BackupCoordinator( context, plugin, @@ -70,6 +77,25 @@ internal class BackupCoordinatorTest : BackupTest() { requiresNetwork = false ) + @Test + fun `isAppEligibleForBackup() exempts plugin provider and blacklisted apps`() { + every { + settingsManager.isBackupEnabled(packageInfo.packageName) + } returns true andThen false andThen true + + every { + packageService.shouldIncludeAppInBackup(packageInfo.packageName) + } answers { callOriginal() } + + 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 `device initialization succeeds and delegates to plugin`() = runBlocking { expectStartNewRestoreSet() 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 4f704dee0..88ba4c15a 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 @@ -87,9 +87,18 @@ internal class RestoreCoordinatorTest : TransportTest() { val sets = restore.getAvailableRestoreSets() ?: fail() assertEquals(2, sets.size) - assertEquals(DEVICE_NAME_FOR_D2D_SET, sets[0].device) + 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