Allow secondary user backup to USB
By default, Android exposes USB devices only to the main user.
In order to query, read and write to it, the signature permission INTERACT_ACROSS_USERS_FULL (optional) is granted to create Seedvault's context as the system user.
Issue: calyxos#437
Issue: https://github.com/seedvault-app/seedvault/issues/77
Change-Id: I0b1b4c8c5aeeb226419ff94e15f631ebe1db66df
diff --git a/README.md b/README.md
index 9ffb0fa..41ccd2e 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@
* `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 a69d866..8fcb13f 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" />
+ <!-- This is needed to query content providers in other users -->
+ <uses-permission
+ android:name="android.permission.INTERACT_ACROSS_USERS_FULL"
+ tools:ignore="ProtectedPermissions" />
+
<application
android:name=".App"
android:allowBackup="false"
diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt
index 2bfdfc7..bcd3d10 100644
--- a/app/src/main/java/com/stevesoltys/seedvault/App.kt
+++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt
@@ -1,12 +1,16 @@
package com.stevesoltys.seedvault
+import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
import android.app.Application
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
import android.app.backup.IBackupManager
+import android.content.Context
import android.content.Context.BACKUP_SERVICE
+import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build
import android.os.ServiceManager.getService
import android.os.StrictMode
+import android.os.UserHandle
import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager
@@ -138,3 +142,8 @@
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 3eaebc7..04a3cb0 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.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 @@
@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 cfd53f4..a174f29 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.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 @@
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 2158d01..18179ee 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.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 @@
@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 9908061..36dd62d 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 @@
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 6bd8846..011e1e1 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 @@
}
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 ead92a8..fcbeaa0 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 @@
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 @@
private val TAG = StorageRootResolver::class.java.simpleName
+ private const val usbAuthority = "com.android.externalstorage.documents"
+
fun getStorageRoots(context: Context, authority: String): List<SafOption> {
val roots = ArrayList<SafOption>()
val rootsUri = DocumentsContract.buildRootsUri(authority)
@@ -34,6 +37,16 @@
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 640c081..d7bf61e 100644
--- a/permissions_com.stevesoltys.seedvault.xml
+++ b/permissions_com.stevesoltys.seedvault.xml
@@ -4,6 +4,7 @@
<permission name="android.permission.BACKUP"/>
<permission name="android.permission.MANAGE_USB"/>
<permission name="android.permission.INSTALL_PACKAGES"/>
+ <permission name="android.permission.INTERACT_ACROSS_USERS_FULL"/>
<permission name="android.permission.WRITE_SECURE_SETTINGS"/>
<permission name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
</privapp-permissions>
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 286f13d..9ee75bc 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 @@
@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 249c733..9fb988a 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 @@
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 @@
@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 @@
return cache.currentFolder
}
- private val contentResolver = context.contentResolver
-
private fun timestampToSnapshot(timestamp: Long): String {
return "$timestamp.SeedSnap"
}
@@ -153,7 +157,7 @@
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 @@
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)