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)