Define new and simpler BackupPlugin API

leaving the old one in place still
This commit is contained in:
Torsten Grote 2021-09-16 13:21:27 +02:00 committed by Chirayu Desai
parent 2932af463c
commit 5d1e3debd1
8 changed files with 206 additions and 73 deletions

View file

@ -3,12 +3,19 @@ package com.stevesoltys.seedvault.plugins.saf
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.backup.BackupPlugin import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.EncryptedMetadata
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
private val TAG = DocumentsProviderBackupPlugin::class.java.simpleName
private const val MIME_TYPE_APK = "application/vnd.android.package-archive" private const val MIME_TYPE_APK = "application/vnd.android.package-archive"
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
@ -45,12 +52,60 @@ internal class DocumentsProviderBackupPlugin(
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getMetadataOutputStream(): OutputStream { override suspend fun hasData(token: Long, name: String): Boolean {
val setDir = storage.getSetDir() ?: throw IOException() val setDir = storage.getSetDir(token) ?: return false
return setDir.findFileBlocking(context, name) != null
}
@Throws(IOException::class)
override suspend fun getOutputStream(token: Long, name: String): OutputStream {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.createOrGetFile(context, name)
return storage.getOutputStream(file)
}
@Throws(IOException::class)
override suspend fun getInputStream(token: Long, name: String): InputStream {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.findFileBlocking(context, name) ?: throw FileNotFoundException()
return storage.getInputStream(file)
}
@Throws(IOException::class)
override suspend fun removeData(token: Long, name: String) {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.findFileBlocking(context, name) ?: return
if (!file.delete()) throw IOException("Failed to delete $name")
}
@Throws(IOException::class)
override suspend fun getMetadataOutputStream(token: Long): OutputStream {
val setDir = storage.getSetDir(token) ?: throw IOException()
val metadataFile = setDir.createOrGetFile(context, FILE_BACKUP_METADATA) val metadataFile = setDir.createOrGetFile(context, FILE_BACKUP_METADATA)
return storage.getOutputStream(metadataFile) return storage.getOutputStream(metadataFile)
} }
@Throws(IOException::class)
override suspend fun hasBackup(uri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
val rootDir = parent.findFileBlocking(context, DIRECTORY_ROOT) ?: return false
val backupSets = getBackups(context, rootDir)
return backupSets.isNotEmpty()
}
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
val rootDir = storage.rootBackupDir ?: return null
val backupSets = getBackups(context, rootDir)
val iterator = backupSets.iterator()
return generateSequence {
if (!iterator.hasNext()) return@generateSequence null // end sequence
val backupSet = iterator.next()
EncryptedMetadata(backupSet.token) {
storage.getInputStream(backupSet.metadataFile)
}
}
}
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getApkOutputStream( override suspend fun getApkOutputStream(
packageInfo: PackageInfo, packageInfo: PackageInfo,
@ -69,3 +124,64 @@ internal class DocumentsProviderBackupPlugin(
} }
} }
class BackupSet(val token: Long, val metadataFile: DocumentFile)
@Suppress("BlockingMethodInNonBlockingContext")
internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>()
val files = try {
// block until the DocumentsProvider has results
rootDir.listFilesBlocking(context)
} catch (e: IOException) {
Log.e(TAG, "Error loading backups from storage", e)
return backupSets
}
for (set in files) {
// retrieve name only once as this causes a DB query
val name = set.name
// get current token from set or continue to next file/set
val token = set.getTokenOrNull(name) ?: continue
// block until children of set are available
val metadata = try {
set.findFileBlocking(context, FILE_BACKUP_METADATA)
} catch (e: IOException) {
Log.e(TAG, "Error reading metadata file in backup set folder: $name", e)
null
}
if (metadata == null) {
Log.w(TAG, "Missing metadata file in backup set folder: $name")
} else {
backupSets.add(BackupSet(token, metadata))
}
}
return backupSets
}
private val tokenRegex = Regex("([0-9]{13})") // good until the year 2286
private val chunkFolderRegex = Regex("[a-f0-9]{2}")
private fun DocumentFile.getTokenOrNull(name: String?): Long? {
val looksLikeToken = name != null && tokenRegex.matches(name)
// check for isDirectory only if we already have a valid token (causes DB query)
if (!looksLikeToken || !isDirectory) {
// only log unexpected output
if (name != null && isUnexpectedFile(name)) {
Log.w(TAG, "Found invalid backup set folder: $name")
}
return null
}
return try {
name?.toLong()
} catch (e: NumberFormatException) {
throw AssertionError(e)
}
}
private fun isUnexpectedFile(name: String): Boolean {
return name != FILE_NO_MEDIA &&
!chunkFolderRegex.matches(name) &&
!name.endsWith(".SeedSnap")
}

View file

@ -24,9 +24,6 @@ internal class DocumentsProviderRestorePlugin(
override val fullRestorePlugin: FullRestorePlugin override val fullRestorePlugin: FullRestorePlugin
) : RestorePlugin { ) : RestorePlugin {
private val tokenRegex = Regex("([0-9]{13})") // good until the year 2286
private val chunkFolderRegex = Regex("[a-f0-9]{2}")
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun hasBackup(uri: Uri): Boolean { override suspend fun hasBackup(uri: Uri): Boolean {
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError() val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
@ -52,61 +49,6 @@ internal class DocumentsProviderRestorePlugin(
} }
} }
private suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>()
val files = try {
// block until the DocumentsProvider has results
rootDir.listFilesBlocking(context)
} catch (e: IOException) {
Log.e(TAG, "Error loading backups from storage", e)
return backupSets
}
for (set in files) {
// retrieve name only once as this causes a DB query
val name = set.name
// get current token from set or continue to next file/set
val token = set.getTokenOrNull(name) ?: continue
// block until children of set are available
val metadata = try {
set.findFileBlocking(context, FILE_BACKUP_METADATA)
} catch (e: IOException) {
Log.e(TAG, "Error reading metadata file in backup set folder: $name", e)
null
}
if (metadata == null) {
Log.w(TAG, "Missing metadata file in backup set folder: $name")
} else {
backupSets.add(BackupSet(token, metadata))
}
}
return backupSets
}
private fun DocumentFile.getTokenOrNull(name: String?): Long? {
val looksLikeToken = name != null && tokenRegex.matches(name)
// check for isDirectory only if we already have a valid token (causes DB query)
if (!looksLikeToken || !isDirectory) {
// only log unexpected output
if (name != null && isUnexpectedFile(name)) {
Log.w(TAG, "Found invalid backup set folder: $name")
}
return null
}
return try {
name?.toLong()
} catch (e: NumberFormatException) {
throw AssertionError(e)
}
}
private fun isUnexpectedFile(name: String): Boolean {
return name != FILE_NO_MEDIA &&
!chunkFolderRegex.matches(name) &&
!name.endsWith(".SeedSnap")
}
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun getApkInputStream( override suspend fun getApkInputStream(
token: Long, token: Long,
@ -120,5 +62,3 @@ internal class DocumentsProviderRestorePlugin(
} }
} }
class BackupSet(val token: Long, val metadataFile: DocumentFile)

View file

@ -28,9 +28,11 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import java.io.IOException import java.io.IOException
import java.io.OutputStream
import java.util.concurrent.TimeUnit.DAYS import java.util.concurrent.TimeUnit.DAYS
import java.util.concurrent.TimeUnit.HOURS import java.util.concurrent.TimeUnit.HOURS
@ -492,4 +494,9 @@ internal class BackupCoordinator(
} }
} }
private suspend fun BackupPlugin.getMetadataOutputStream(): OutputStream {
val token = settingsManager.getToken() ?: throw IOException("no current token")
return getOutputStream(token, FILE_BACKUP_METADATA)
}
} }

View file

@ -2,13 +2,18 @@ package com.stevesoltys.seedvault.transport.backup
import android.app.backup.RestoreSet import android.app.backup.RestoreSet
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.net.Uri
import androidx.annotation.WorkerThread
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
interface BackupPlugin { interface BackupPlugin {
@Deprecated("Use methods in this interface instead")
val kvBackupPlugin: KVBackupPlugin val kvBackupPlugin: KVBackupPlugin
@Deprecated("Use methods in this interface instead")
val fullBackupPlugin: FullBackupPlugin val fullBackupPlugin: FullBackupPlugin
/** /**
@ -25,16 +30,60 @@ interface BackupPlugin {
@Throws(IOException::class) @Throws(IOException::class)
suspend fun initializeDevice() suspend fun initializeDevice()
/**
* Return true if there is data stored for the given name.
*/
@Throws(IOException::class)
suspend fun hasData(token: Long, name: String): Boolean
/**
* Return a raw byte stream for writing data for the given name.
*/
@Throws(IOException::class)
suspend fun getOutputStream(token: Long, name: String): OutputStream
/**
* Return a raw byte stream with data for the given name.
*/
@Throws(IOException::class)
suspend fun getInputStream(token: Long, name: String): InputStream
/**
* Remove all data associated with the given name.
*/
@Throws(IOException::class)
suspend fun removeData(token: Long, name: String)
/** /**
* Returns an [OutputStream] for writing backup metadata. * Returns an [OutputStream] for writing backup metadata.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
suspend fun getMetadataOutputStream(): OutputStream @Deprecated("use getOutputStream() instead")
suspend fun getMetadataOutputStream(token: Long): OutputStream
/**
* Searches if there's really a backup available in the given location.
* Returns true if at least one was found and false otherwise.
*
* FIXME: Passing a Uri is too plugin-specific and should be handled differently
*/
@WorkerThread
@Throws(IOException::class)
suspend fun hasBackup(uri: Uri): Boolean
/**
* Get the set of all backups currently available for restore.
*
* @return metadata for the set of restore images available,
* or null if an error occurred (the attempt should be rescheduled).
**/
suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>?
/** /**
* Returns an [OutputStream] for writing an APK to be backed up. * Returns an [OutputStream] for writing an APK to be backed up.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
@Deprecated("Use getOutputStream() instead")
suspend fun getApkOutputStream(packageInfo: PackageInfo, suffix: String): OutputStream suspend fun getApkOutputStream(packageInfo: PackageInfo, suffix: String): OutputStream
/** /**
@ -48,3 +97,5 @@ interface BackupPlugin {
val providerPackageName: String? val providerPackageName: String?
} }
class EncryptedMetadata(val token: Long, val inputStreamRetriever: () -> InputStream)

View file

@ -4,6 +4,7 @@ import android.content.pm.PackageInfo
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
@Deprecated("Use BackupPlugin instead")
interface FullBackupPlugin { interface FullBackupPlugin {
fun getQuota(): Long fun getQuota(): Long

View file

@ -4,6 +4,7 @@ import android.content.pm.PackageInfo
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
@Deprecated("Use BackupPlugin instead")
interface KVBackupPlugin { interface KVBackupPlugin {
/** /**

View file

@ -16,6 +16,7 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.transport.backup.ApkBackup import com.stevesoltys.seedvault.transport.backup.ApkBackup
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.backup.BackupPlugin import com.stevesoltys.seedvault.transport.backup.BackupPlugin
@ -166,7 +167,10 @@ internal class CoordinatorIntegrationTest : TransportTest() {
any() any()
) )
} returns packageMetadata } returns packageMetadata
coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream every { settingsManager.getToken() } returns token
coEvery {
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
} returns metadataOutputStream
every { every {
metadataManager.onApkBackedUp( metadataManager.onApkBackedUp(
packageInfo, packageInfo,
@ -244,7 +248,10 @@ internal class CoordinatorIntegrationTest : TransportTest() {
} returns bOutputStream } returns bOutputStream
every { kvBackupPlugin.packageFinished(packageInfo) } just Runs every { kvBackupPlugin.packageFinished(packageInfo) } just Runs
coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null coEvery { apkBackup.backupApkIfNecessary(packageInfo, UNKNOWN_ERROR, any()) } returns null
coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream every { settingsManager.getToken() } returns token
coEvery {
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
} returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
// start and finish K/V backup // start and finish K/V backup
@ -296,7 +303,10 @@ internal class CoordinatorIntegrationTest : TransportTest() {
any() any()
) )
} returns packageMetadata } returns packageMetadata
coEvery { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream every { settingsManager.getToken() } returns token
coEvery {
backupPlugin.getOutputStream(token, FILE_BACKUP_METADATA)
} returns metadataOutputStream
every { every {
metadataManager.onApkBackedUp( metadataManager.onApkBackedUp(
packageInfo, packageInfo,

View file

@ -21,6 +21,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs import io.mockk.Runs
@ -85,7 +86,7 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `device initialization succeeds and delegates to plugin`() = runBlocking { fun `device initialization succeeds and delegates to plugin`() = runBlocking {
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } just Runs coEvery { plugin.initializeDevice() } just Runs
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs
every { kv.hasState() } returns false every { kv.hasState() } returns false
every { full.hasState() } returns false every { full.hasState() } returns false
@ -251,7 +252,8 @@ internal class BackupCoordinatorTest : BackupTest() {
every { kv.hasState() } returns true every { kv.hasState() } returns true
every { full.hasState() } returns false every { full.hasState() } returns false
every { kv.getCurrentPackage() } returns packageInfo every { kv.getCurrentPackage() } returns packageInfo
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream every { settingsManager.getToken() } returns token
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
every { kv.finishBackup() } returns result every { kv.finishBackup() } returns result
every { metadataOutputStream.close() } just Runs every { metadataOutputStream.close() } just Runs
@ -268,7 +270,8 @@ internal class BackupCoordinatorTest : BackupTest() {
every { kv.hasState() } returns false every { kv.hasState() } returns false
every { full.hasState() } returns true every { full.hasState() } returns true
every { full.getCurrentPackage() } returns packageInfo every { full.getCurrentPackage() } returns packageInfo
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream every { settingsManager.getToken() } returns token
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs every { metadataManager.onPackageBackedUp(packageInfo, metadataOutputStream) } just Runs
every { full.finishBackup() } returns result every { full.finishBackup() } returns result
every { metadataOutputStream.close() } just Runs every { metadataOutputStream.close() } just Runs
@ -412,7 +415,8 @@ internal class BackupCoordinatorTest : BackupTest() {
coEvery { coEvery {
apkBackup.backupApkIfNecessary(notAllowedPackages[1], WAS_STOPPED, any()) apkBackup.backupApkIfNecessary(notAllowedPackages[1], WAS_STOPPED, any())
} returns packageMetadata } returns packageMetadata
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream every { settingsManager.getToken() } returns token
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every { every {
metadataManager.onApkBackedUp( metadataManager.onApkBackedUp(
notAllowedPackages[1], notAllowedPackages[1],
@ -445,7 +449,8 @@ internal class BackupCoordinatorTest : BackupTest() {
} returns oldPackageMetadata } returns oldPackageMetadata
// state differs now, was stopped before // state differs now, was stopped before
every { oldPackageMetadata.state } returns WAS_STOPPED every { oldPackageMetadata.state } returns WAS_STOPPED
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream every { settingsManager.getToken() } returns token
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every { every {
metadataManager.onPackageBackupError( metadataManager.onPackageBackupError(
packageInfo, packageInfo,
@ -473,7 +478,8 @@ internal class BackupCoordinatorTest : BackupTest() {
every { every {
metadataManager.getPackageMetadata(packageInfo.packageName) metadataManager.getPackageMetadata(packageInfo.packageName)
} returns null } returns null
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream every { settingsManager.getToken() } returns token
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every { every {
metadataManager.onPackageBackupError( metadataManager.onPackageBackupError(
packageInfo, packageInfo,
@ -499,7 +505,8 @@ internal class BackupCoordinatorTest : BackupTest() {
any() any()
) )
} returns packageMetadata } returns packageMetadata
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream every { settingsManager.getToken() } returns token
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
every { every {
metadataManager.onApkBackedUp( metadataManager.onApkBackedUp(
any(), any(),