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.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.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.USE_BIOMETRIC` to authenticate saving a new recovery code
|
||||||
|
* `android.permission.INTERACT_ACROSS_USERS_FULL` to use storage roots in other users (optional).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
Bug reports and pull requests are welcome on GitHub at https://github.com/seedvault-app/seedvault.
|
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:name="com.stevesoltys.seedvault.RESTORE_BACKUP"
|
||||||
android:protectionLevel="system|signature" />
|
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
|
<application
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
package com.stevesoltys.seedvault
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
|
import android.Manifest.permission.INTERACT_ACROSS_USERS_FULL
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
|
import android.app.backup.BackupManager.PACKAGE_MANAGER_SENTINEL
|
||||||
import android.app.backup.IBackupManager
|
import android.app.backup.IBackupManager
|
||||||
|
import android.content.Context
|
||||||
import android.content.Context.BACKUP_SERVICE
|
import android.content.Context.BACKUP_SERVICE
|
||||||
|
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.ServiceManager.getService
|
import android.os.ServiceManager.getService
|
||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
|
import android.os.UserHandle
|
||||||
import com.stevesoltys.seedvault.crypto.cryptoModule
|
import com.stevesoltys.seedvault.crypto.cryptoModule
|
||||||
import com.stevesoltys.seedvault.header.headerModule
|
import com.stevesoltys.seedvault.header.headerModule
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
|
@ -138,3 +142,8 @@ fun <T> permitDiskReads(func: () -> T): T {
|
||||||
func()
|
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.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.stevesoltys.seedvault.getSystemContext
|
||||||
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
@ -16,11 +17,15 @@ private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
internal class DocumentsProviderStoragePlugin(
|
internal class DocumentsProviderStoragePlugin(
|
||||||
private val context: Context,
|
private val appContext: Context,
|
||||||
private val storage: DocumentsStorage,
|
private val storage: DocumentsStorage,
|
||||||
) : StoragePlugin {
|
) : 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)
|
@Throws(IOException::class)
|
||||||
override suspend fun startNewRestoreSet(token: Long) {
|
override suspend fun startNewRestoreSet(token: Long) {
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.database.ContentObserver
|
import android.database.ContentObserver
|
||||||
|
@ -15,6 +16,7 @@ import android.provider.DocumentsContract.getDocumentId
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.stevesoltys.seedvault.getSystemContext
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.settings.Storage
|
import com.stevesoltys.seedvault.settings.Storage
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
@ -40,11 +42,15 @@ const val MIME_TYPE = "application/octet-stream"
|
||||||
private val TAG = DocumentsStorage::class.java.simpleName
|
private val TAG = DocumentsStorage::class.java.simpleName
|
||||||
|
|
||||||
internal class DocumentsStorage(
|
internal class DocumentsStorage(
|
||||||
private val context: Context,
|
private val appContext: Context,
|
||||||
private val settingsManager: SettingsManager,
|
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
|
internal var storage: Storage? = null
|
||||||
get() {
|
get() {
|
||||||
|
|
|
@ -5,6 +5,7 @@ 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.os.UserHandle
|
||||||
import androidx.annotation.UiThread
|
import androidx.annotation.UiThread
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
@ -121,7 +122,8 @@ class SettingsManager(private val context: Context) {
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun canDoBackupNow(): Boolean {
|
fun canDoBackupNow(): Boolean {
|
||||||
val storage = getStorage() ?: return false
|
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 {
|
fun backupApks(): Boolean {
|
||||||
|
|
|
@ -3,15 +3,20 @@ package com.stevesoltys.seedvault.storage
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||||
|
import com.stevesoltys.seedvault.getSystemContext
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||||
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
|
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
|
||||||
import javax.crypto.SecretKey
|
import javax.crypto.SecretKey
|
||||||
|
|
||||||
internal class SeedvaultStoragePlugin(
|
internal class SeedvaultStoragePlugin(
|
||||||
context: Context,
|
private val appContext: Context,
|
||||||
private val storage: DocumentsStorage,
|
private val storage: DocumentsStorage,
|
||||||
private val keyManager: KeyManager,
|
private val keyManager: KeyManager,
|
||||||
) : SafStoragePlugin(context) {
|
) : SafStoragePlugin(appContext) {
|
||||||
|
override val context: Context
|
||||||
|
get() = appContext.getSystemContext {
|
||||||
|
storage.storage?.isUsb == true
|
||||||
|
}
|
||||||
override val root: DocumentFile
|
override val root: DocumentFile
|
||||||
get() = storage.rootBackupDir ?: error("No storage set")
|
get() = storage.rootBackupDir ?: error("No storage set")
|
||||||
|
|
||||||
|
|
|
@ -32,7 +32,6 @@ internal class RestoreStorageViewModel(
|
||||||
}
|
}
|
||||||
if (hasBackup) {
|
if (hasBackup) {
|
||||||
saveStorage(uri)
|
saveStorage(uri)
|
||||||
|
|
||||||
mLocationChecked.postEvent(LocationResult())
|
mLocationChecked.postEvent(LocationResult())
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Location was rejected: $uri")
|
Log.w(TAG, "Location was rejected: $uri")
|
||||||
|
|
|
@ -3,6 +3,7 @@ package com.stevesoltys.seedvault.ui.storage
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.UserHandle
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
|
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
|
||||||
import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID
|
import android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID
|
||||||
|
@ -23,6 +24,8 @@ internal object StorageRootResolver {
|
||||||
|
|
||||||
private val TAG = StorageRootResolver::class.java.simpleName
|
private val TAG = StorageRootResolver::class.java.simpleName
|
||||||
|
|
||||||
|
private const val usbAuthority = "com.android.externalstorage.documents"
|
||||||
|
|
||||||
fun getStorageRoots(context: Context, authority: String): List<SafOption> {
|
fun getStorageRoots(context: Context, authority: String): List<SafOption> {
|
||||||
val roots = ArrayList<SafOption>()
|
val roots = ArrayList<SafOption>()
|
||||||
val rootsUri = DocumentsContract.buildRootsUri(authority)
|
val rootsUri = DocumentsContract.buildRootsUri(authority)
|
||||||
|
@ -34,6 +37,16 @@ internal object StorageRootResolver {
|
||||||
if (root != null) roots.add(root)
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to load some roots from $authority", e)
|
Log.w(TAG, "Failed to load some roots from $authority", e)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
<permission name="android.permission.BACKUP"/>
|
<permission name="android.permission.BACKUP"/>
|
||||||
<permission name="android.permission.MANAGE_USB"/>
|
<permission name="android.permission.MANAGE_USB"/>
|
||||||
<permission name="android.permission.INSTALL_PACKAGES"/>
|
<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.WRITE_SECURE_SETTINGS"/>
|
||||||
<permission name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
<permission name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||||
</privapp-permissions>
|
</privapp-permissions>
|
||||||
|
|
|
@ -11,10 +11,11 @@ import javax.crypto.SecretKey
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
class TestSafStoragePlugin(
|
class TestSafStoragePlugin(
|
||||||
private val context: Context,
|
private val appContext: Context,
|
||||||
private val getLocationUri: () -> Uri?,
|
private val getLocationUri: () -> Uri?,
|
||||||
) : SafStoragePlugin(context) {
|
) : SafStoragePlugin(appContext) {
|
||||||
|
|
||||||
|
override val context = appContext
|
||||||
override val root: DocumentFile?
|
override val root: DocumentFile?
|
||||||
get() {
|
get() {
|
||||||
val uri = getLocationUri() ?: return null
|
val uri = getLocationUri() ?: return null
|
||||||
|
|
|
@ -28,12 +28,18 @@ internal const val CHUNK_FOLDER_COUNT = 256
|
||||||
|
|
||||||
private const val TAG = "SafStoragePlugin"
|
private const val TAG = "SafStoragePlugin"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param appContext application context provided by the storage module
|
||||||
|
*/
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
public abstract class SafStoragePlugin(
|
public abstract class SafStoragePlugin(
|
||||||
private val context: Context,
|
private val appContext: Context,
|
||||||
) : StoragePlugin {
|
) : StoragePlugin {
|
||||||
|
|
||||||
private val cache = SafCache()
|
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?
|
protected abstract val root: DocumentFile?
|
||||||
|
|
||||||
private val folder: DocumentFile?
|
private val folder: DocumentFile?
|
||||||
|
@ -44,7 +50,7 @@ public abstract class SafStoragePlugin(
|
||||||
@SuppressLint("HardwareIds")
|
@SuppressLint("HardwareIds")
|
||||||
// this is unique to each combination of app-signing key, user, and device
|
// 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
|
// 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
|
// the folder name is our user ID
|
||||||
val folderName = "$androidId.sv"
|
val folderName = "$androidId.sv"
|
||||||
cache.currentFolder = try {
|
cache.currentFolder = try {
|
||||||
|
@ -56,8 +62,6 @@ public abstract class SafStoragePlugin(
|
||||||
return cache.currentFolder
|
return cache.currentFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
private val contentResolver = context.contentResolver
|
|
||||||
|
|
||||||
private fun timestampToSnapshot(timestamp: Long): String {
|
private fun timestampToSnapshot(timestamp: Long): String {
|
||||||
return "$timestamp.SeedSnap"
|
return "$timestamp.SeedSnap"
|
||||||
}
|
}
|
||||||
|
@ -153,7 +157,7 @@ public abstract class SafStoragePlugin(
|
||||||
val name = timestampToSnapshot(timestamp)
|
val name = timestampToSnapshot(timestamp)
|
||||||
// TODO should we check if it exists first?
|
// TODO should we check if it exists first?
|
||||||
val snapshotFile = folder.createFileOrThrow(name, MIME_TYPE)
|
val snapshotFile = folder.createFileOrThrow(name, MIME_TYPE)
|
||||||
return snapshotFile.getOutputStream(contentResolver)
|
return snapshotFile.getOutputStream(context.contentResolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
/************************* Restore *******************************/
|
/************************* Restore *******************************/
|
||||||
|
@ -188,7 +192,7 @@ public abstract class SafStoragePlugin(
|
||||||
val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) {
|
val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) {
|
||||||
getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp))
|
getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp))
|
||||||
} ?: throw IOException("Could not get file for snapshot $timestamp")
|
} ?: throw IOException("Could not get file for snapshot $timestamp")
|
||||||
return snapshotFile.getInputStream(contentResolver)
|
return snapshotFile.getInputStream(context.contentResolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
|
|
Loading…
Reference in a new issue