Skip to content

Commit

Permalink
Allow secondary user backup to USB
Browse files Browse the repository at this point in the history
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: #77
Change-Id: I0b1b4c8c5aeeb226419ff94e15f631ebe1db66df
  • Loading branch information
Uldiniad authored and chirayudesai committed Apr 29, 2022
1 parent fa93d5d commit dd57828
Show file tree
Hide file tree
Showing 12 changed files with 67 additions and 16 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/stevesoltys/seedvault/App.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ internal class RestoreStorageViewModel(
}
if (hasBackup) {
saveStorage(uri)

mLocationChecked.postEvent(LocationResult())
} else {
Log.w(TAG, "Location was rejected: $uri")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions permissions_com.stevesoltys.seedvault.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand All @@ -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"
}
Expand Down Expand Up @@ -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 *******************************/
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit dd57828

Please sign in to comment.