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

Add experimental support for forcing D2D transfer backups
This commit is contained in:
Torsten Grote 2024-01-15 11:24:27 -03:00 committed by GitHub
commit 0319d733c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 342 additions and 69 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<PackageInfo>(3).packageName
dataIntercept = ByteArrayOutputStream()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<MetadataWriter> { MetadataWriterImpl(get()) }
single<MetadataReader> { MetadataReaderImpl(get()) }
}

View file

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

View file

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

View file

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

View file

@ -55,9 +55,16 @@ internal class AppListRetriever(
@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> {

View file

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

View file

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

View file

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

View file

@ -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.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
@ -20,7 +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
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 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
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 @@ 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
}
}
/**

View file

@ -143,12 +143,9 @@ internal class BackupCoordinator(
@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
}
/**

View file

@ -8,7 +8,9 @@ val backupModule = module {
single {
PackageService(
context = androidContext(),
backupManager = get()
backupManager = get(),
settingsManager = get(),
plugin = get()
)
}
single {

View file

@ -17,6 +17,8 @@ import android.util.Log
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 @@ 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
@ -45,13 +49,16 @@ internal class PackageService(
// 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 @@ internal class PackageService(
return packageArray.toTypedArray()
}
/**
* A list of packages that will not be backed up.
*/
val notBackedUpPackages: List<PackageInfo>
@WorkerThread
get() {
@ -94,16 +104,23 @@ internal class PackageService(
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 @@ internal class PackageService(
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 @@ 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
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 @@ 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

View file

@ -22,10 +22,19 @@ 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
/**
* 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 @@ internal class RestoreCoordinator(
**/
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 @@ internal class RestoreCoordinator(
*/
fun beforeStartRestore(backupMetadata: BackupMetadata) {
this.backupMetadata = backupMetadata
if (backupMetadata.d2dBackup) {
settingsManager.setD2dBackupsEnabled(true)
}
}
/**
@ -219,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)) {
@ -228,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 ->
@ -261,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()

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.header.headerModule
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 @@ class TestApp : App() {
}
private val appModule = module {
single { Clock() }
single { SettingsManager(this@TestApp) }
}
override fun startKoin() = startKoin {

View file

@ -19,6 +19,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
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.mockk
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 @@ class MetadataManagerTest {
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 @@ class MetadataManagerTest {
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 @@ class MetadataManagerTest {
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()

View file

@ -59,6 +59,10 @@ internal abstract class TransportTest {
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)

View file

@ -32,8 +32,6 @@ import io.mockk.mockk
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 @@ internal class BackupCoordinatorTest : BackupTest() {
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,
@ -170,20 +168,6 @@ internal class BackupCoordinatorTest : BackupTest() {
verify { metadataOutputStream.close() }
}
@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

View file

@ -90,6 +90,15 @@ internal class RestoreCoordinatorTest : TransportTest() {
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