From daaf0e0ecc8bcceef5cf423314f0a81435676deb Mon Sep 17 00:00:00 2001 From: Steve Soltys Date: Fri, 12 Jan 2024 00:12:43 -0500 Subject: [PATCH] Add experimental support for forcing D2D transfer backups --- .github/scripts/run_tests.sh | 4 +- .github/workflows/build.yml | 14 ++- .github/workflows/test.yml | 3 +- app/build.gradle.kts | 7 +- app/development/scripts/provision_emulator.sh | 2 +- .../seedvault/KoinInstrumentationTestApp.kt | 5 + .../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 +- .../transport/backup/PackageServiceTest.kt | 39 +++++++ .../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 | 22 ++++ .../seedvault/settings/SettingsFragment.kt | 2 +- .../seedvault/settings/SettingsManager.kt | 12 +- .../transport/ConfigurableBackupTransport.kt | 13 ++- .../transport/backup/BackupModule.kt | 1 + .../transport/backup/PackageService.kt | 106 ++++++++++++------ .../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 | 36 +++++- .../seedvault/transport/TransportTest.kt | 4 + .../transport/backup/BackupCoordinatorTest.kt | 2 +- .../restore/RestoreCoordinatorTest.kt | 11 +- 34 files changed, 325 insertions(+), 70 deletions(-) diff --git a/.github/scripts/run_tests.sh b/.github/scripts/run_tests.sh index 48a86572..b680a247 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/build.yml b/.github/workflows/build.yml index 52dd750b..3f2e5486 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 @@ jobs: 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 a624c775..8aeddf71 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 cb2151cf..52a0e183 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 fef04b8b..284e7082 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/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt index d3eff96d..c00438f2 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 @@ class KoinInstrumentationTestApp : App() { 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 70f58974..0886c682 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 3d74aede..be95877d 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 86f14a28..69d0cf62 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 2d2be5f1..e2fa7d9f 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 3223aa52..4c5e3b6c 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/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/transport/backup/PackageServiceTest.kt index f140eb22..ca54566d 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 @@ class PackageServiceTest : KoinComponent { 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().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().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/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index 3cdc3365..f3403de7 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 c4808331..c8ad6aac 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 98a57f4a..f58a29a1 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 68c723a4..0d7ed1a2 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 382a1757..bbd6df19 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 1359c112..bbed50c7 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 f1c33c16..2c3abb3c 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 29cb923c..e185b79b 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 f4a2effd..c7e7d378 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,25 @@ class ExpertSettingsFragment : PreferenceFragmentCompat() { createFileLauncher.launch(name) true } + + val quotaPreference = findPreference(PREF_KEY_UNLIMITED_QUOTA) + + quotaPreference?.setOnPreferenceChangeListener { _, newValue -> + quotaPreference.isChecked = newValue as Boolean + true + } + + val d2dPreference = findPreference(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 93ed0883..38aaf805 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 a83c844f..b7ab7c69 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" @@ -30,7 +31,8 @@ private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId" 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 @@ 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 a90b14b0..cead1eab 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/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index 14fd6261..bf7d3272 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,6 +8,7 @@ val backupModule = module { single { PackageService( context = androidContext(), + backupManager = get(), settingsManager = get(), plugin = get() ) 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 d298856e..7d16e173 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 @@ -1,5 +1,6 @@ package com.stevesoltys.seedvault.transport.backup +import android.app.backup.IBackupManager import android.content.Context import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP import android.content.pm.ApplicationInfo.FLAG_STOPPED @@ -11,6 +12,7 @@ import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_INSTRUMENTATION import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.os.RemoteException +import android.os.UserHandle import android.util.Log import android.util.Log.INFO import androidx.annotation.WorkerThread @@ -28,11 +30,13 @@ private const val LOG_MAX_PACKAGES = 100 */ 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 @WorkerThread @@ -48,13 +52,13 @@ 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 = 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)) { @@ -69,6 +73,9 @@ internal class PackageService( return packageArray.toTypedArray() } + /** + * A list of packages that will not be backed up. + */ val notBackedUpPackages: List @WorkerThread get() { @@ -97,16 +104,23 @@ internal class PackageService( val userApps: List @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 @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 @@ -132,11 +146,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 +172,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 +226,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 +237,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 f6a00ecc..68431258 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 502ba39c..d5daf945 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 + 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. 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 11e8497c..0125bf4e 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 = mockk() private val backup = BackupCoordinator( context, 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 4f704dee..88ba4c15 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