Add support for d2d

Co-authored-by: Tommy Webb <tommy@calyxinstitute.org>
Change-Id: I61d88a511a0f81e6d384e3650f6797725da79814
This commit is contained in:
Oliver Scott 2022-11-16 13:08:51 -05:00 committed by t-m-w
parent befc1b1a77
commit f0575ddd6a
10 changed files with 123 additions and 50 deletions

View file

@ -5,7 +5,10 @@ import android.hardware.usb.UsbDevice
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.util.Log
import androidx.annotation.UiThread
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PRIVATE
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
@ -34,6 +37,8 @@ private const val PREF_KEY_UNLIMITED_QUOTA = "unlimited_quota"
class SettingsManager(private val context: Context) {
private val TAG = SettingsManager::class.java.simpleName
private val prefs = permitDiskReads {
PreferenceManager.getDefaultSharedPreferences(context)
}
@ -41,12 +46,19 @@ class SettingsManager(private val context: Context) {
@Volatile
private var token: Long? = null
/**
* The StoragePlugin provider package name is assigned by ConfigurableBackupTransportService
* to ensure it is excluded; if the provider is killed, the backup or restore will fail.
*/
var pluginProviderPackageName: String? = null
/**
* This gets accessed by non-UI threads when saving with [PreferenceManager]
* and when [isBackupEnabled] is called during a backup run.
* Therefore, it is implemented with a thread-safe [ConcurrentSkipListSet].
*/
private val blacklistedApps: MutableSet<String> by lazy {
@VisibleForTesting(otherwise = PRIVATE)
val blacklistedApps: MutableSet<String> by lazy {
ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()))
}
@ -132,6 +144,15 @@ class SettingsManager(private val context: Context) {
fun isBackupEnabled(packageName: String) = !blacklistedApps.contains(packageName)
fun isAppAllowedForBackup(packageName: String): Boolean {
// Check that the app is not blacklisted by the user
val enabled = 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 && packageName != pluginProviderPackageName
}
fun isStorageBackupEnabled() = prefs.getBoolean(PREF_KEY_BACKUP_STORAGE, false)
@UiThread

View file

@ -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
@ -22,7 +23,7 @@ import org.koin.core.component.inject
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 backup opt-out apps that target API 30.
// we are pretending to be one here. This will backup opt-out apps that target at least API 30.
const val TRANSPORT_FLAGS = FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED or FLAG_DEVICE_TO_DEVICE_TRANSFER
private const val TRANSPORT_DIRECTORY_NAME =
@ -38,6 +39,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
@ -120,9 +122,9 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
override fun isAppEligibleForBackup(
targetPackage: PackageInfo,
isFullBackup: Boolean,
@Suppress("UNUSED_PARAMETER") isFullBackup: Boolean,
): Boolean {
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup)
return settingsManager.isAppAllowedForBackup(targetPackage.packageName)
}
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking {

View file

@ -11,6 +11,8 @@ import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver
@ -65,7 +67,13 @@ fun requestBackup(context: Context) {
val backupManager: IBackupManager = get().get()
if (backupManager.isBackupEnabled) {
val packageService: PackageService = get().get()
val packages = packageService.eligiblePackages
val settingsManager: SettingsManager = get().get()
val plugin: StoragePlugin = get().get()
// SettingsManager must know the StoragePlugin provider package name to exclude it when
// isAppAllowedForBackup is called.
settingsManager.pluginProviderPackageName = plugin.providerPackageName
val packages = packageService.requestedPackages
val appTotals = packageService.expectedAppTotals
val result = try {

View file

@ -138,19 +138,6 @@ internal class BackupCoordinator(
TRANSPORT_ERROR
}
fun isAppEligibleForBackup(
targetPackage: PackageInfo,
@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
}
/**
* Ask the transport about current quota for backup size of the package.
*

View file

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

View file

@ -1,6 +1,5 @@
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
@ -17,6 +16,7 @@ import android.util.Log
import android.util.Log.INFO
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.settings.SettingsManager
private val TAG = PackageService::class.java.simpleName
@ -28,13 +28,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 packageManager: PackageManager = context.packageManager
private val myUserId = UserHandle.myUserId()
val eligiblePackages: Array<String>
val requestedPackages: Array<String>
@WorkerThread
@Throws(RemoteException::class)
get() {
@ -45,22 +45,27 @@ 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 =
backupManager.filterAppsEligibleForBackupForUser(myUserId, packages.toTypedArray())
// As a result of switching to D2D, we can no longer use BackupManager's method
// filterAppsEligibleForBackupForUser, because it will not return all of the expected
// apps; it is not designed to determine MIGRATION eligibility, only eligibility for
// inclusion in *scheduled* BACKUPs, which are implicitly *not* D2D migrations.
// None of the other eligibility methods are exposed by AOSP APIs. On the other hand,
// the actual backup process properly utilizes OperationType.MIGRATION and performs its
// own checks as to whether apps are allowed to be backed up. All we need to do now is
// filter out apps that *we* want to be excluded. The system will do the rest later.
val requestedApps = packages.filter { settingsManager.isAppAllowedForBackup(it) }
// log eligible packages
// log requested packages
if (Log.isLoggable(TAG, INFO)) {
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:")
logPackages(eligibleApps.toList())
Log.i(TAG, "Filtering left ${requestedApps.size} requested packages:")
logPackages(requestedApps.toList())
}
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data
val packageArray = eligibleApps.toMutableList()
val packageArray = requestedApps.toMutableList()
packageArray.add(MAGIC_PACKAGE_MANAGER)
return packageArray.toTypedArray()
@ -159,7 +164,13 @@ internal fun PackageInfo.isSystemApp(): Boolean {
internal fun PackageInfo.allowsBackup(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false
return applicationInfo.flags and FLAG_ALLOW_BACKUP != 0
// 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
}
/**

View file

@ -26,6 +26,14 @@ import com.stevesoltys.seedvault.transport.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 DEVICE_NAME_FOR_D2D_SET = "D2D"
private data class RestoreCoordinatorState(
val token: Long,
val packages: Iterator<PackageInfo>,
@ -92,7 +100,8 @@ internal class RestoreCoordinator(
**/
suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
return getAvailableMetadata()?.map { (_, metadata) ->
RestoreSet(metadata.deviceName, metadata.deviceName, metadata.token, TRANSPORT_FLAGS)
RestoreSet(metadata.deviceName /* name */, DEVICE_NAME_FOR_D2D_SET /* device */,
metadata.token, TRANSPORT_FLAGS)
}?.toTypedArray()
}

View file

@ -0,0 +1,51 @@
package com.stevesoltys.seedvault.settings
import com.stevesoltys.seedvault.transport.TransportTest
import io.mockk.every
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
internal class SettingsManagerTest : TransportTest() {
private val userExcludedApps = mutableSetOf<String>()
@Test
fun `isAppAllowedForBackup() exempts plugin provider and blacklisted apps`() {
val pkgName = packageInfo.packageName
preparePartiallyMockedSettingsManager()
settingsManager.pluginProviderPackageName = pkgName
assertFalse(settingsManager.isAppAllowedForBackup(pkgName),
"Plugin provider must not be allowed for backup")
settingsManager.pluginProviderPackageName = "new.package"
userExcludedApps.add(pkgName)
assertFalse(settingsManager.isAppAllowedForBackup(pkgName),
"Excluded package must not be allowed for backup")
userExcludedApps.remove(pkgName)
assertTrue(settingsManager.isAppAllowedForBackup(pkgName),
"Non-excluded package should be allowed if it is not the plugin provider")
}
private fun preparePartiallyMockedSettingsManager() {
// Use our own app exclusion set to avoid the uninitialized set in mocked SettingsManager.
every { settingsManager.blacklistedApps } answers { userExcludedApps }
// Always call isAppAllowedForBackup unmocked rather than an unimplemented mock.
every { settingsManager.isAppAllowedForBackup(any()) } answers { callOriginal() }
// Always call isBackupEnabled unmocked.
every { settingsManager.isBackupEnabled(any()) } answers { callOriginal() }
// Always set the underlying provider name field for use by unmocked isAppAllowedForBackup.
every {
settingsManager.pluginProviderPackageName = any()
} propertyType String::class answers {
fieldValue = value
}
}
}

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
@ -183,20 +181,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

@ -87,7 +87,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
val sets = restore.getAvailableRestoreSets() ?: fail()
assertEquals(2, sets.size)
assertEquals(metadata.deviceName, sets[0].device)
assertEquals(DEVICE_NAME_FOR_D2D_SET, sets[0].device)
assertEquals(metadata.deviceName, sets[0].name)
assertEquals(metadata.token, sets[0].token)
}