diff --git a/README.md b/README.md index 9ffb0fae..41ccd2e9 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ It uses the same internal APIs as `adb backup` which is deprecated and thus need * `android.permission.FOREGROUND_SERVICE` to do periodic storage backups without interruption. * `android.permission.MANAGE_DOCUMENTS` to retrieve the available storage roots (optional) for better UX. * `android.permission.USE_BIOMETRIC` to authenticate saving a new recovery code +* `android.permission.INTERACT_ACROSS_USERS_FULL` to use storage roots in other users (optional). ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a69d8664..8fcb13f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -60,6 +60,11 @@ android:name="com.stevesoltys.seedvault.RESTORE_BACKUP" android:protectionLevel="system|signature" /> + + + permitDiskReads(func: () -> T): T { func() } } + +fun Context.getSystemContext(isUsbStorage: () -> Boolean): Context { + return if (checkSelfPermission(INTERACT_ACROSS_USERS_FULL) == PERMISSION_GRANTED + && isUsbStorage()) createContextAsUser(UserHandle.SYSTEM, 0) else this +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt index 3eaebc73..04a3cb00 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsProviderStoragePlugin.kt @@ -5,6 +5,7 @@ import android.content.pm.PackageManager import android.net.Uri import android.util.Log import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.seedvault.getSystemContext import com.stevesoltys.seedvault.plugins.EncryptedMetadata import com.stevesoltys.seedvault.plugins.StoragePlugin import java.io.FileNotFoundException @@ -16,11 +17,15 @@ private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName @Suppress("BlockingMethodInNonBlockingContext") internal class DocumentsProviderStoragePlugin( - private val context: Context, + private val appContext: Context, private val storage: DocumentsStorage, ) : StoragePlugin { - private val packageManager: PackageManager = context.packageManager + private val context: Context get() = appContext.getSystemContext { + storage.storage?.isUsb == true + } + + private val packageManager: PackageManager = appContext.packageManager @Throws(IOException::class) override suspend fun startNewRestoreSet(token: Long) { diff --git a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt index cfd53f41..a174f29d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/plugins/saf/DocumentsStorage.kt @@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.plugins.saf +import android.content.ContentResolver import android.content.Context import android.content.pm.PackageInfo import android.database.ContentObserver @@ -15,6 +16,7 @@ import android.provider.DocumentsContract.getDocumentId import android.util.Log import androidx.annotation.VisibleForTesting import androidx.documentfile.provider.DocumentFile +import com.stevesoltys.seedvault.getSystemContext import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.Storage import kotlinx.coroutines.TimeoutCancellationException @@ -40,11 +42,15 @@ const val MIME_TYPE = "application/octet-stream" private val TAG = DocumentsStorage::class.java.simpleName internal class DocumentsStorage( - private val context: Context, + private val appContext: Context, private val settingsManager: SettingsManager, ) { - private val contentResolver = context.contentResolver + private val context: Context get() = appContext.getSystemContext { + storage?.isUsb ?: false + } + + private val contentResolver: ContentResolver get() = context.contentResolver internal var storage: Storage? = null get() { 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 2158d018..18179eed 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsManager.kt @@ -5,6 +5,7 @@ import android.hardware.usb.UsbDevice import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.Uri +import android.os.UserHandle import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.documentfile.provider.DocumentFile @@ -121,7 +122,8 @@ class SettingsManager(private val context: Context) { @WorkerThread fun canDoBackupNow(): Boolean { val storage = getStorage() ?: return false - return !storage.isUnavailableUsb(context) && !storage.isUnavailableNetwork(context) + return !storage.isUnavailableUsb(context.createContextAsUser(UserHandle.SYSTEM, 0)) + && !storage.isUnavailableNetwork(context) } fun backupApks(): Boolean { diff --git a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt index 9908061a..36dd62dc 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/storage/SeedvaultStoragePlugin.kt @@ -3,15 +3,20 @@ package com.stevesoltys.seedvault.storage import android.content.Context import androidx.documentfile.provider.DocumentFile import com.stevesoltys.seedvault.crypto.KeyManager +import com.stevesoltys.seedvault.getSystemContext import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin import javax.crypto.SecretKey internal class SeedvaultStoragePlugin( - context: Context, + private val appContext: Context, private val storage: DocumentsStorage, private val keyManager: KeyManager, -) : SafStoragePlugin(context) { +) : SafStoragePlugin(appContext) { + override val context: Context + get() = appContext.getSystemContext { + storage.storage?.isUsb == true + } override val root: DocumentFile get() = storage.rootBackupDir ?: error("No storage set") diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt index 6bd88464..011e1e10 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/RestoreStorageViewModel.kt @@ -32,7 +32,6 @@ internal class RestoreStorageViewModel( } if (hasBackup) { saveStorage(uri) - mLocationChecked.postEvent(LocationResult()) } else { Log.w(TAG, "Location was rejected: $uri") diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt index ead92a8f..fcbeaa0f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/storage/StorageRootResolver.kt @@ -3,6 +3,7 @@ package com.stevesoltys.seedvault.ui.storage import android.content.Context import android.database.Cursor import android.graphics.drawable.Drawable +import android.os.UserHandle import android.provider.DocumentsContract import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID @@ -23,6 +24,8 @@ internal object StorageRootResolver { private val TAG = StorageRootResolver::class.java.simpleName + private const val usbAuthority = "com.android.externalstorage.documents" + fun getStorageRoots(context: Context, authority: String): List { val roots = ArrayList() val rootsUri = DocumentsContract.buildRootsUri(authority) @@ -34,6 +37,16 @@ internal object StorageRootResolver { if (root != null) roots.add(root) } } + if (usbAuthority == authority && UserHandle.myUserId() != UserHandle.USER_SYSTEM) { + val c: Context = context.createContextAsUser(UserHandle.SYSTEM, 0) + c.contentResolver.query(rootsUri, null, null, null, null)?.use { cursor -> + while (cursor.moveToNext()) { + // Pass in context since it is used to query package manager for app icons + val root = getStorageRoot(context, authority, cursor) + if (root != null && root.isUsb) roots.add(root) + } + } + } } catch (e: Exception) { Log.w(TAG, "Failed to load some roots from $authority", e) } diff --git a/permissions_com.stevesoltys.seedvault.xml b/permissions_com.stevesoltys.seedvault.xml index 640c0811..d7bf61ed 100644 --- a/permissions_com.stevesoltys.seedvault.xml +++ b/permissions_com.stevesoltys.seedvault.xml @@ -4,6 +4,7 @@ + diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt index 286f13d5..9ee75bc7 100644 --- a/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt +++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/plugin/TestSafStoragePlugin.kt @@ -11,10 +11,11 @@ import javax.crypto.SecretKey @Suppress("BlockingMethodInNonBlockingContext") class TestSafStoragePlugin( - private val context: Context, + private val appContext: Context, private val getLocationUri: () -> Uri?, -) : SafStoragePlugin(context) { +) : SafStoragePlugin(appContext) { + override val context = appContext override val root: DocumentFile? get() { val uri = getLocationUri() ?: return null diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafStoragePlugin.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafStoragePlugin.kt index 249c733c..9fb988ac 100644 --- a/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafStoragePlugin.kt +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/plugin/saf/SafStoragePlugin.kt @@ -28,12 +28,18 @@ internal const val CHUNK_FOLDER_COUNT = 256 private const val TAG = "SafStoragePlugin" +/** + * @param appContext application context provided by the storage module + */ @Suppress("BlockingMethodInNonBlockingContext") public abstract class SafStoragePlugin( - private val context: Context, + private val appContext: Context, ) : StoragePlugin { private val cache = SafCache() + // In the case of USB storage, if INTERACT_ACROSS_USERS_FULL is granted, this context will match + // the system user's application context. Otherwise, matches appContext. + protected abstract val context: Context protected abstract val root: DocumentFile? private val folder: DocumentFile? @@ -44,7 +50,7 @@ public abstract class SafStoragePlugin( @SuppressLint("HardwareIds") // this is unique to each combination of app-signing key, user, and device // so we don't leak anything by not hashing this and can use it as is - val androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID) + val androidId = Settings.Secure.getString(appContext.contentResolver, ANDROID_ID) // the folder name is our user ID val folderName = "$androidId.sv" cache.currentFolder = try { @@ -56,8 +62,6 @@ public abstract class SafStoragePlugin( return cache.currentFolder } - private val contentResolver = context.contentResolver - private fun timestampToSnapshot(timestamp: Long): String { return "$timestamp.SeedSnap" } @@ -153,7 +157,7 @@ public abstract class SafStoragePlugin( val name = timestampToSnapshot(timestamp) // TODO should we check if it exists first? val snapshotFile = folder.createFileOrThrow(name, MIME_TYPE) - return snapshotFile.getOutputStream(contentResolver) + return snapshotFile.getOutputStream(context.contentResolver) } /************************* Restore *******************************/ @@ -188,7 +192,7 @@ public abstract class SafStoragePlugin( val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) { getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp)) } ?: throw IOException("Could not get file for snapshot $timestamp") - return snapshotFile.getInputStream(contentResolver) + return snapshotFile.getInputStream(context.contentResolver) } @Throws(IOException::class)