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.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.PRIVATE
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
@ -34,6 +37,8 @@ private const val PREF_KEY_UNLIMITED_QUOTA = "unlimited_quota"
class SettingsManager(private val context: Context) { class SettingsManager(private val context: Context) {
private val TAG = SettingsManager::class.java.simpleName
private val prefs = permitDiskReads { private val prefs = permitDiskReads {
PreferenceManager.getDefaultSharedPreferences(context) PreferenceManager.getDefaultSharedPreferences(context)
} }
@ -41,12 +46,19 @@ class SettingsManager(private val context: Context) {
@Volatile @Volatile
private var token: Long? = null 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] * This gets accessed by non-UI threads when saving with [PreferenceManager]
* and when [isBackupEnabled] is called during a backup run. * and when [isBackupEnabled] is called during a backup run.
* Therefore, it is implemented with a thread-safe [ConcurrentSkipListSet]. * 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())) 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 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) fun isStorageBackupEnabled() = prefs.getBoolean(PREF_KEY_BACKUP_STORAGE, false)
@UiThread @UiThread

View file

@ -12,6 +12,7 @@ import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.settings.SettingsActivity import com.stevesoltys.seedvault.settings.SettingsActivity
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -22,7 +23,7 @@ import org.koin.core.component.inject
val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name val TRANSPORT_ID: String = ConfigurableBackupTransport::class.java.name
// Since there seems to be consensus in the community to pose as device to device transport, // 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 const val TRANSPORT_FLAGS = FLAG_CLIENT_SIDE_ENCRYPTION_ENABLED or FLAG_DEVICE_TO_DEVICE_TRANSFER
private const val TRANSPORT_DIRECTORY_NAME = 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 backupCoordinator by inject<BackupCoordinator>()
private val restoreCoordinator by inject<RestoreCoordinator>() private val restoreCoordinator by inject<RestoreCoordinator>()
private val settingsManager by inject<SettingsManager>()
override fun transportDirName(): String { override fun transportDirName(): String {
return TRANSPORT_DIRECTORY_NAME return TRANSPORT_DIRECTORY_NAME
@ -120,9 +122,9 @@ class ConfigurableBackupTransport internal constructor(private val context: Cont
override fun isAppEligibleForBackup( override fun isAppEligibleForBackup(
targetPackage: PackageInfo, targetPackage: PackageInfo,
isFullBackup: Boolean, @Suppress("UNUSED_PARAMETER") isFullBackup: Boolean,
): Boolean { ): Boolean {
return backupCoordinator.isAppEligibleForBackup(targetPackage, isFullBackup) return settingsManager.isAppAllowedForBackup(targetPackage.packageName)
} }
override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking { override fun getBackupQuota(packageName: String, isFullBackup: Boolean): Long = runBlocking {

View file

@ -11,6 +11,8 @@ import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.BackupMonitor import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.crypto.KeyManager 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.transport.backup.PackageService
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver import com.stevesoltys.seedvault.ui.notification.NotificationBackupObserver
@ -65,7 +67,13 @@ fun requestBackup(context: Context) {
val backupManager: IBackupManager = get().get() val backupManager: IBackupManager = get().get()
if (backupManager.isBackupEnabled) { if (backupManager.isBackupEnabled) {
val packageService: PackageService = get().get() 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 appTotals = packageService.expectedAppTotals
val result = try { val result = try {

View file

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

View file

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

View file

@ -1,6 +1,5 @@
package com.stevesoltys.seedvault.transport.backup package com.stevesoltys.seedvault.transport.backup
import android.app.backup.IBackupManager
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
import android.content.pm.ApplicationInfo.FLAG_STOPPED import android.content.pm.ApplicationInfo.FLAG_STOPPED
@ -17,6 +16,7 @@ import android.util.Log
import android.util.Log.INFO import android.util.Log.INFO
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.settings.SettingsManager
private val TAG = PackageService::class.java.simpleName private val TAG = PackageService::class.java.simpleName
@ -28,13 +28,13 @@ private const val LOG_MAX_PACKAGES = 100
*/ */
internal class PackageService( internal class PackageService(
private val context: Context, private val context: Context,
private val backupManager: IBackupManager, private val settingsManager: SettingsManager,
) { ) {
private val packageManager: PackageManager = context.packageManager private val packageManager: PackageManager = context.packageManager
private val myUserId = UserHandle.myUserId() private val myUserId = UserHandle.myUserId()
val eligiblePackages: Array<String> val requestedPackages: Array<String>
@WorkerThread @WorkerThread
@Throws(RemoteException::class) @Throws(RemoteException::class)
get() { get() {
@ -45,22 +45,27 @@ internal class PackageService(
// log packages // log packages
if (Log.isLoggable(TAG, INFO)) { if (Log.isLoggable(TAG, INFO)) {
Log.i(TAG, "Got ${packages.size} packages:") Log.i(TAG, "Got ${packages.size} packages:")
packages.chunked(LOG_MAX_PACKAGES).forEach { logPackages(packages)
Log.i(TAG, it.toString())
}
} }
val eligibleApps = // As a result of switching to D2D, we can no longer use BackupManager's method
backupManager.filterAppsEligibleForBackupForUser(myUserId, packages.toTypedArray()) // 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)) { if (Log.isLoggable(TAG, INFO)) {
Log.i(TAG, "Filtering left ${eligibleApps.size} eligible packages:") Log.i(TAG, "Filtering left ${requestedApps.size} requested packages:")
logPackages(eligibleApps.toList()) logPackages(requestedApps.toList())
} }
// add magic @pm@ package (PACKAGE_MANAGER_SENTINEL) which holds package manager data // 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) packageArray.add(MAGIC_PACKAGE_MANAGER)
return packageArray.toTypedArray() return packageArray.toTypedArray()
@ -159,7 +164,13 @@ internal fun PackageInfo.isSystemApp(): Boolean {
internal fun PackageInfo.allowsBackup(): Boolean { internal fun PackageInfo.allowsBackup(): Boolean {
if (packageName == MAGIC_PACKAGE_MANAGER || applicationInfo == null) return false 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 com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import java.io.IOException 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( private data class RestoreCoordinatorState(
val token: Long, val token: Long,
val packages: Iterator<PackageInfo>, val packages: Iterator<PackageInfo>,
@ -92,7 +100,8 @@ internal class RestoreCoordinator(
**/ **/
suspend fun getAvailableRestoreSets(): Array<RestoreSet>? { suspend fun getAvailableRestoreSets(): Array<RestoreSet>? {
return getAvailableMetadata()?.map { (_, metadata) -> 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() }?.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 io.mockk.verify
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals 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 org.junit.jupiter.api.Test
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
@ -183,20 +181,6 @@ internal class BackupCoordinatorTest : BackupTest() {
verify { metadataOutputStream.close() } 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 @Test
fun `clearing KV backup data throws`() = runBlocking { fun `clearing KV backup data throws`() = runBlocking {
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token

View file

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