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
This commit is contained in:
parent
fa93d5dfcc
commit
dd57828697
12 changed files with 67 additions and 16 deletions
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 @@ fun <T> 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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -32,7 +32,6 @@ internal class RestoreStorageViewModel(
|
|||
}
|
||||
if (hasBackup) {
|
||||
saveStorage(uri)
|
||||
|
||||
mLocationChecked.postEvent(LocationResult())
|
||||
} else {
|
||||
Log.w(TAG, "Location was rejected: $uri")
|
||||
|
|
|
@ -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<SafOption> {
|
||||
val roots = ArrayList<SafOption>()
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue