Introduce MetadataManager to handle all metadata related to backups

This now updates the metadata on remote storage and internal cache
after each successful package backup.
This commit is contained in:
Torsten Grote 2019-12-18 13:14:25 -03:00
parent e1d55c8a4e
commit b9cac5ea87
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
27 changed files with 361 additions and 135 deletions

View file

@ -13,6 +13,7 @@ android {
minSdkVersion 29
targetSdkVersion 29
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments disableAnalytics: 'true'
}
buildTypes {
@ -35,6 +36,9 @@ android {
events "passed", "skipped", "failed"
}
}
unitTests {
includeAndroidResources = true
}
}
sourceSets {
@ -119,10 +123,14 @@ dependencies {
lintChecks 'com.github.thirdegg:lint-rules:0.0.4-alpha'
def junit_version = "5.5.2"
testImplementation aospDeps
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.2'
testImplementation 'androidx.test.ext:junit:1.1.1'
testImplementation 'org.robolectric:robolectric:4.3.1'
testImplementation "org.junit.jupiter:junit-jupiter-api:$junit_version"
testImplementation 'io.mockk:mockk:1.9.3'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.5.2'
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junit_version"
testRuntimeOnly "org.junit.vintage:junit-vintage-engine:$junit_version"
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'

View file

@ -3,9 +3,10 @@ package com.stevesoltys.seedvault
import androidx.documentfile.provider.DocumentFile
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.createOrGetFile
import com.stevesoltys.seedvault.settings.SettingsManager
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertNotNull
@ -22,8 +23,9 @@ private const val filename = "test-file"
class DocumentsStorageTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val metadataManager by inject<MetadataManager>()
private val settingsManager by inject<SettingsManager>()
private val storage = DocumentsStorage(context, settingsManager)
private val storage = DocumentsStorage(context, metadataManager, settingsManager)
private lateinit var file: DocumentFile

View file

@ -9,11 +9,11 @@ import android.os.ServiceManager.getService
import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
import com.stevesoltys.seedvault.restore.RestoreViewModel
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.SettingsViewModel
import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
import com.stevesoltys.seedvault.transport.restore.restoreModule
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
@ -33,9 +33,10 @@ class App : Application() {
private val appModule = module {
single { SettingsManager(this@App) }
single { BackupNotificationManager(this@App) }
single { Clock() }
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
viewModel { SettingsViewModel(this@App, get(), get()) }
viewModel { SettingsViewModel(this@App, get(), get(), get()) }
viewModel { RecoveryCodeViewModel(this@App, get()) }
viewModel { BackupStorageViewModel(this@App, get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) }

View file

@ -10,7 +10,6 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat.*
import com.stevesoltys.seedvault.settings.SettingsActivity
import java.util.*
private const val CHANNEL_ID_OBSERVER = "NotificationBackupObserver"
private const val CHANNEL_ID_ERROR = "NotificationError"
@ -48,7 +47,7 @@ class BackupNotificationManager(private val context: Context) {
val notification = observerBuilder.apply {
setContentTitle(context.getString(R.string.notification_title))
setContentText(app)
setWhen(Date().time)
setWhen(System.currentTimeMillis())
setProgress(expected, transferred, false)
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
}.build()
@ -64,7 +63,7 @@ class BackupNotificationManager(private val context: Context) {
val notification = observerBuilder.apply {
setContentTitle(title)
setContentText(app)
setWhen(Date().time)
setWhen(System.currentTimeMillis())
priority = if (userInitiated) PRIORITY_DEFAULT else PRIORITY_LOW
}.build()
nm.notify(NOTIFICATION_ID_OBSERVER, notification)
@ -82,7 +81,7 @@ class BackupNotificationManager(private val context: Context) {
val notification = errorBuilder.apply {
setContentTitle(context.getString(R.string.notification_error_title))
setContentText(context.getString(R.string.notification_error_text))
setWhen(Date().time)
setWhen(System.currentTimeMillis())
setOnlyAlertOnce(true)
setAutoCancel(true)
mActions = arrayListOf(action)

View file

@ -0,0 +1,13 @@
package com.stevesoltys.seedvault
/**
* This class only exists, so we can mock the time in tests.
*/
class Clock {
/**
* Returns the current time in milliseconds (Unix time).
*/
fun time(): Long {
return System.currentTimeMillis()
}
}

View file

@ -11,20 +11,20 @@ import android.net.Uri
import android.os.Handler
import android.provider.DocumentsContract
import android.util.Log
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import org.koin.core.KoinComponent
import org.koin.core.inject
import java.util.*
import org.koin.core.context.GlobalContext.get
import java.util.concurrent.TimeUnit.HOURS
private val TAG = UsbIntentReceiver::class.java.simpleName
class UsbIntentReceiver : UsbMonitor(), KoinComponent {
class UsbIntentReceiver : UsbMonitor() {
private val settingsManager by inject<SettingsManager>()
private val settingsManager: SettingsManager by lazy { get().koin.get<SettingsManager>() }
private val metadataManager: MetadataManager by lazy { get().koin.get<MetadataManager>() }
override fun shouldMonitorStatus(context: Context, action: String, device: UsbDevice): Boolean {
if (action != ACTION_USB_DEVICE_ATTACHED) return false
@ -33,7 +33,7 @@ class UsbIntentReceiver : UsbMonitor(), KoinComponent {
val attachedFlashDrive = FlashDrive.from(device)
return if (savedFlashDrive == attachedFlashDrive) {
Log.d(TAG, "Matches stored device, checking backup time...")
if (Date().time - settingsManager.getBackupTime() >= HOURS.toMillis(24)) {
if (System.currentTimeMillis() - metadataManager.getLastBackupTime() >= HOURS.toMillis(24)) {
Log.d(TAG, "Last backup older than 24 hours, requesting a backup...")
true
} else {

View file

@ -7,11 +7,11 @@ import java.io.InputStream
data class BackupMetadata(
internal val version: Byte = VERSION,
internal val token: Long,
internal val time: Long = System.currentTimeMillis(),
internal var time: Long = 0L,
internal val androidVersion: Int = Build.VERSION.SDK_INT,
internal val androidIncremental: String = Build.VERSION.INCREMENTAL,
internal val deviceName: String = "${Build.MANUFACTURER} ${Build.MODEL}",
internal val packageMetadata: Map<String, PackageMetadata> = HashMap()
internal val packageMetadata: HashMap<String, PackageMetadata> = HashMap()
)
internal const val JSON_METADATA = "@meta@"
@ -23,7 +23,7 @@ internal const val JSON_METADATA_INCREMENTAL = "incremental"
internal const val JSON_METADATA_NAME = "name"
data class PackageMetadata(
internal val time: Long,
internal var time: Long,
internal val version: Long? = null,
internal val installer: String? = null,
internal val signatures: List<String>? = null

View file

@ -0,0 +1,119 @@
package com.stevesoltys.seedvault.metadata
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.Clock
import java.io.FileNotFoundException
import java.io.IOException
import java.io.OutputStream
private val TAG = MetadataManager::class.java.simpleName
@VisibleForTesting
internal const val METADATA_CACHE_FILE = "metadata.cache"
@WorkerThread
class MetadataManager(
private val context: Context,
private val clock: Clock,
private val metadataWriter: MetadataWriter,
private val metadataReader: MetadataReader) {
private val uninitializedMetadata = BackupMetadata(token = 0L)
private var metadata: BackupMetadata = uninitializedMetadata
get() {
if (field == uninitializedMetadata) {
field = try {
getMetadataFromCache() ?: throw IOException()
} catch (e: IOException) {
// create new default metadata
// Attention: If this happens due to a read error, we will overwrite remote metadata
Log.w(TAG, "Creating new metadata...")
BackupMetadata(token = clock.time())
}
}
return field
}
/**
* Call this when initializing a new device.
*
* A new backup token will be generated.
* Existing [BackupMetadata] will be cleared
* and written encrypted to the given [OutputStream] as well as the internal cache.
*/
@Synchronized
@Throws(IOException::class)
fun onDeviceInitialization(metadataOutputStream: OutputStream) {
metadata = BackupMetadata(token = clock.time())
metadataWriter.write(metadata, metadataOutputStream)
writeMetadataToCache()
}
/**
* Call this after a package has been backed up successfully.
*
* It updates the packages' metadata
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
*/
@Synchronized
@Throws(IOException::class)
fun onPackageBackedUp(packageName: String, metadataOutputStream: OutputStream) {
val oldMetadata = metadata.copy()
val now = clock.time()
metadata.time = now
if (metadata.packageMetadata.containsKey(packageName)) {
metadata.packageMetadata[packageName]?.time = now
} else {
metadata.packageMetadata[packageName] = PackageMetadata(time = now)
}
try {
metadataWriter.write(metadata, metadataOutputStream)
} catch (e: IOException) {
Log.w(TAG, "Error writing metadata to storage", e)
// revert metadata and do not write it to cache
metadata = oldMetadata
throw IOException(e)
}
writeMetadataToCache()
}
@Synchronized
fun getBackupToken(): Long = metadata.token
/**
* Returns the last backup time in unix epoch milli seconds.
*
* Note that this might be a blocking I/O call.
*/
@Synchronized
fun getLastBackupTime(): Long = metadata.time
@Synchronized
@VisibleForTesting
private fun getMetadataFromCache(): BackupMetadata? {
try {
with(context.openFileInput(METADATA_CACHE_FILE)) {
return metadataReader.decode(readBytes())
}
} catch (e: SecurityException) {
Log.e(TAG, "Error parsing cached metadata", e)
return null
} catch (e: FileNotFoundException) {
Log.d(TAG, "Cached metadata not found, creating...")
return null
}
}
@Synchronized
@VisibleForTesting
@Throws(IOException::class)
private fun writeMetadataToCache() {
with(context.openFileOutput(METADATA_CACHE_FILE, MODE_PRIVATE)) {
write(metadataWriter.encode(metadata))
}
}
}

View file

@ -1,8 +1,10 @@
package com.stevesoltys.seedvault.metadata
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val metadataModule = module {
single { MetadataManager(androidContext(), get(), get(), get()) }
single<MetadataWriter> { MetadataWriterImpl(get()) }
single<MetadataReader> { MetadataReaderImpl(get()) }
}

View file

@ -1,6 +1,5 @@
package com.stevesoltys.seedvault.metadata
import androidx.annotation.VisibleForTesting
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.UnsupportedVersionException
@ -16,6 +15,9 @@ interface MetadataReader {
@Throws(SecurityException::class, DecryptionFailedException::class, UnsupportedVersionException::class, IOException::class)
fun readMetadata(inputStream: InputStream, expectedToken: Long): BackupMetadata
@Throws(SecurityException::class)
fun decode(bytes: ByteArray, expectedVersion: Byte? = null, expectedToken: Long? = null): BackupMetadata
}
internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
@ -33,9 +35,8 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
return decode(metadataBytes, version, expectedToken)
}
@VisibleForTesting
@Throws(SecurityException::class)
internal fun decode(bytes: ByteArray, expectedVersion: Byte, expectedToken: Long): BackupMetadata {
override fun decode(bytes: ByteArray, expectedVersion: Byte?, expectedToken: Long?): BackupMetadata {
// NOTE: We don't do extensive validation of the parsed input here,
// because it was encrypted with authentication, so we should be able to trust it.
//
@ -46,11 +47,11 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
// get backup metadata and check expectations
val meta = json.getJSONObject(JSON_METADATA)
val version = meta.getInt(JSON_METADATA_VERSION).toByte()
if (version != expectedVersion) {
if (expectedVersion != null && version != expectedVersion) {
throw SecurityException("Invalid version '${version.toInt()}' in metadata, expected '${expectedVersion.toInt()}'.")
}
val token = meta.getLong(JSON_METADATA_TOKEN)
if (token != expectedToken) {
if (expectedToken != null && token != expectedToken) {
throw SecurityException("Invalid token '$token' in metadata, expected '$expectedToken'.")
}
// get package metadata

View file

@ -1,6 +1,5 @@
package com.stevesoltys.seedvault.metadata
import androidx.annotation.VisibleForTesting
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import org.json.JSONArray
@ -9,23 +8,21 @@ import java.io.IOException
import java.io.OutputStream
interface MetadataWriter {
@Throws(IOException::class)
fun write(outputStream: OutputStream, token: Long)
fun write(metadata: BackupMetadata, outputStream: OutputStream)
fun encode(metadata: BackupMetadata): ByteArray
}
internal class MetadataWriterImpl(private val crypto: Crypto): MetadataWriter {
internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
@Throws(IOException::class)
override fun write(outputStream: OutputStream, token: Long) {
val metadata = BackupMetadata(token = token)
override fun write(metadata: BackupMetadata, outputStream: OutputStream) {
outputStream.write(ByteArray(1).apply { this[0] = metadata.version })
crypto.encryptMultipleSegments(outputStream, encode(metadata))
}
@VisibleForTesting
internal fun encode(metadata: BackupMetadata): ByteArray {
override fun encode(metadata: BackupMetadata): ByteArray {
val json = JSONObject().apply {
put(JSON_METADATA, JSONObject().apply {
put(JSON_METADATA_VERSION, metadata.version.toInt())

View file

@ -6,7 +6,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val documentsProviderModule = module {
single { DocumentsStorage(androidContext(), get()) }
single { DocumentsStorage(androidContext(), get(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(get(), androidContext().packageManager) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) }
}

View file

@ -9,6 +9,7 @@ import android.provider.DocumentsContract.*
import android.provider.DocumentsContract.Document.*
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage
import libcore.io.IoUtils.closeQuietly
@ -28,10 +29,10 @@ private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage(
private val context: Context,
private val settingsManager: SettingsManager) {
private val metadataManager: MetadataManager,
settingsManager: SettingsManager) {
private val storage: Storage? = settingsManager.getStorage()
private val token: Long = settingsManager.getBackupToken()
internal val rootBackupDir: DocumentFile? by lazy {
val parent = storage?.getDocumentFile(context) ?: return@lazy null
@ -47,10 +48,7 @@ internal class DocumentsStorage(
}
private val currentToken: Long by lazy {
if (token != 0L) token
else settingsManager.getAndSaveNewBackupToken().apply {
Log.d(TAG, "Using a fresh backup token: $this")
}
metadataManager.getBackupToken()
}
private val currentSetDir: DocumentFile? by lazy {

View file

@ -18,6 +18,7 @@ import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.lifecycle.Observer
import androidx.preference.Preference
import androidx.preference.Preference.OnPreferenceChangeListener
import androidx.preference.PreferenceFragmentCompat
@ -28,7 +29,6 @@ import com.stevesoltys.seedvault.isMassStorage
import com.stevesoltys.seedvault.restore.RestoreActivity
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import java.util.*
private val TAG = SettingsFragment::class.java.name
@ -94,6 +94,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
return@OnPreferenceChangeListener false
}
}
viewModel.lastBackupTime.observe(this, Observer { time -> setBackupLocationSummary(time) })
}
override fun onStart() {
@ -105,8 +107,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
storage = settingsManager.getStorage()
setBackupState()
setAutoRestoreState()
setBackupLocationSummary()
setMenuItemStates()
viewModel.updateLastBackupTime()
if (storage?.isUsb == true) context?.registerReceiver(usbReceiver, usbFilter)
}
@ -159,16 +161,15 @@ class SettingsFragment : PreferenceFragmentCompat() {
}
}
private fun setBackupLocationSummary() {
private fun setBackupLocationSummary(lastBackupInMillis: Long) {
// get name of storage location
val storageName = storage?.name ?: getString(R.string.settings_backup_location_none)
// get time of last backup
val lastBackupInMillis = settingsManager.getBackupTime()
// set time of last backup
val lastBackup = if (lastBackupInMillis == 0L) {
getString(R.string.settings_backup_last_backup_never)
} else {
getRelativeTimeSpanString(lastBackupInMillis, Date().time, MINUTE_IN_MILLIS, 0)
getRelativeTimeSpanString(lastBackupInMillis, System.currentTimeMillis(), MINUTE_IN_MILLIS, 0)
}
backupLocation.summary = getString(R.string.settings_backup_location_summary, storageName, lastBackup)
}

View file

@ -5,7 +5,6 @@ import android.hardware.usb.UsbDevice
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import java.util.*
private const val PREF_KEY_STORAGE_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName"
@ -16,9 +15,6 @@ private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber"
private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId"
private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_BACKUP_TOKEN = "backupToken"
private const val PREF_KEY_BACKUP_TIME = "backupTime"
class SettingsManager(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
@ -66,48 +62,6 @@ class SettingsManager(context: Context) {
return FlashDrive(name, serialNumber, vendorId, productId)
}
/**
* Generates and returns a new backup token while saving it as well.
* Subsequent calls to [getBackupToken] will return this new token once saved.
*/
fun getAndSaveNewBackupToken(): Long = Date().time.apply {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TOKEN, this)
.apply()
}
/**
* Returns the current backup token or 0 if none exists.
*/
fun getBackupToken(): Long {
return prefs.getLong(PREF_KEY_BACKUP_TOKEN, 0L)
}
/**
* Sets the last backup time to "now".
*/
fun saveNewBackupTime() {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TIME, Date().time)
.apply()
}
/**
* Sets the last backup time to "never".
*/
fun resetBackupTime() {
prefs.edit()
.putLong(PREF_KEY_BACKUP_TIME, 0L)
.apply()
}
/**
* Returns the last backup time in unix epoch milli seconds.
*/
fun getBackupTime(): Long {
return prefs.getLong(PREF_KEY_BACKUP_TIME, 0L)
}
}
data class Storage(

View file

@ -1,19 +1,30 @@
package com.stevesoltys.seedvault.settings
import android.app.Application
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.transport.requestBackup
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
class SettingsViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager
keyManager: KeyManager,
private val metadataManager: MetadataManager
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
override val isRestoreOperation = false
fun backupNow() {
private val _lastBackupTime = MutableLiveData<Long>()
internal val lastBackupTime: LiveData<Long> = _lastBackupTime
internal fun updateLastBackupTime() {
Thread { _lastBackupTime.postValue(metadataManager.getLastBackupTime()) }.start()
}
internal fun backupNow() {
Thread { requestBackup(app) }.start()
}

View file

@ -7,7 +7,7 @@ import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.MetadataWriter
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS
@ -23,7 +23,7 @@ internal class BackupCoordinator(
private val plugin: BackupPlugin,
private val kv: KVBackup,
private val full: FullBackup,
private val metadataWriter: MetadataWriter,
private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager,
private val nm: BackupNotificationManager) {
@ -56,7 +56,7 @@ internal class BackupCoordinator(
Log.i(TAG, "Initialize Device!")
return try {
plugin.initializeDevice()
writeBackupMetadata(settingsManager.getBackupToken())
metadataManager.onDeviceInitialization(plugin.getMetadataOutputStream())
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
@ -102,15 +102,15 @@ internal class BackupCoordinator(
}
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
val packageName = packageInfo.packageName
// backups of package manager metadata do not respect backoff
// we need to reject them manually when now is not a good time for a backup
if (packageInfo.packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
if (packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
return TRANSPORT_PACKAGE_REJECTED
}
val result = kv.performBackup(packageInfo, data, flags)
if (result == TRANSPORT_OK) settingsManager.saveNewBackupTime()
return result
return onPackageBackedUp(result, packageInfo)
}
// ------------------------------------------------------------------------------------
@ -138,8 +138,7 @@ internal class BackupCoordinator(
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
val result = full.performFullBackup(targetPackage, fileDescriptor, flags)
if (result == TRANSPORT_OK) settingsManager.saveNewBackupTime()
return result
return onPackageBackedUp(result, targetPackage)
}
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
@ -193,10 +192,17 @@ internal class BackupCoordinator(
else -> throw IllegalStateException("Unexpected state in finishBackup()")
}
@Throws(IOException::class)
private fun writeBackupMetadata(token: Long) {
val outputStream = plugin.getMetadataOutputStream()
metadataWriter.write(outputStream, token)
private fun onPackageBackedUp(result: Int, packageInfo: PackageInfo): Int {
if (result != TRANSPORT_OK) return result
val packageName = packageInfo.packageName
try {
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onPackageBackedUp(packageName, outputStream)
} catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e)
return TRANSPORT_PACKAGE_REJECTED
}
return result
}
private fun getBackupBackoff(): Long {

View file

@ -10,8 +10,8 @@ import android.os.ParcelFileDescriptor
import android.util.Log
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.metadata.DecryptionFailedException
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.settings.SettingsManager
import libcore.io.IoUtils.closeQuietly
import java.io.IOException
@ -22,7 +22,7 @@ private class RestoreCoordinatorState(
private val TAG = RestoreCoordinator::class.java.simpleName
internal class RestoreCoordinator(
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager,
private val plugin: RestorePlugin,
private val kv: KVRestore,
private val full: FullRestore,
@ -76,7 +76,7 @@ internal class RestoreCoordinator(
* or 0 if there is no backup set available corresponding to the current device state.
*/
fun getCurrentRestoreSet(): Long {
return settingsManager.getBackupToken()
return metadataManager.getBackupToken()
.apply { Log.i(TAG, "Got current restore set token: $this") }
}

View file

@ -25,10 +25,7 @@ internal class BackupStorageViewModel(
override fun onLocationSet(uri: Uri) {
val isUsb = saveStorage(uri)
// use a new backup token
settingsManager.getAndSaveNewBackupToken()
// initialize the new location
// initialize the new location, will also generate a new backup token
val observer = InitializationObserver()
backupManager.initializeTransportsForUser(UserHandle.myUserId(), arrayOf(TRANSPORT_ID), observer)

View file

@ -96,9 +96,6 @@ internal abstract class StorageViewModel(
val storage = Storage(uri, name, root.isUsb)
settingsManager.setStorage(storage)
// reset time of last backup to "Never"
settingsManager.resetBackupTime()
if (storage.isUsb) {
Log.d(TAG, "Selected storage is a removable USB device.")
val wasSaved = saveUsbDevice()

View file

@ -0,0 +1,124 @@
package com.stevesoltys.seedvault.metadata
import android.content.Context
import android.content.Context.MODE_PRIVATE
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.stopKoin
import java.io.*
@RunWith(AndroidJUnit4::class)
class MetadataManagerTest {
private val context: Context = mockk()
private val clock: Clock = mockk()
private val metadataWriter: MetadataWriter = mockk()
private val metadataReader: MetadataReader = mockk()
private val manager = MetadataManager(context, clock, metadataWriter, metadataReader)
private val time = 42L
private val initialMetadata = BackupMetadata(token = time)
private val storageOutputStream = ByteArrayOutputStream()
private val cacheOutputStream: FileOutputStream = mockk()
private val cacheInputStream: FileInputStream = mockk()
private val encodedMetadata = getRandomByteArray()
@After
fun afterEachTest() {
stopKoin()
}
@Test
fun `test onDeviceInitialization()`() {
every { clock.time() } returns time
every { metadataWriter.write(initialMetadata, storageOutputStream) } just Runs
expectWriteToCache(initialMetadata)
manager.onDeviceInitialization(storageOutputStream)
assertEquals(time, manager.getBackupToken())
assertEquals(0L, manager.getLastBackupTime())
}
@Test
fun `test onPackageBackedUp()`() {
val packageName = getRandomString()
val updatedMetadata = initialMetadata.copy()
updatedMetadata.time = time
updatedMetadata.packageMetadata[packageName] = PackageMetadata(time)
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
every { clock.time() } returns time
every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs
expectWriteToCache(updatedMetadata)
manager.onPackageBackedUp(packageName, storageOutputStream)
assertEquals(time, manager.getLastBackupTime())
}
@Test
fun `test onPackageBackedUp() fails to write to storage`() {
val packageName = getRandomString()
val updatedMetadata = initialMetadata.copy()
updatedMetadata.time = time
updatedMetadata.packageMetadata[packageName] = PackageMetadata(time)
every { context.openFileInput(METADATA_CACHE_FILE) } throws FileNotFoundException()
every { clock.time() } returns time
every { metadataWriter.write(updatedMetadata, storageOutputStream) } throws IOException()
try {
manager.onPackageBackedUp(packageName, storageOutputStream)
fail()
} catch (e: IOException) {
// expected
}
assertEquals(0L, manager.getLastBackupTime()) // time was reverted
// TODO also assert reverted PackageMetadata once possible
}
@Test
fun `test onPackageBackedUp() with filled cache`() {
val cachedPackageName = getRandomString()
val packageName = getRandomString()
val byteArray = ByteArray(DEFAULT_BUFFER_SIZE)
val cachedMetadata = initialMetadata.copy(time = 23)
cachedMetadata.packageMetadata[cachedPackageName] = PackageMetadata(23)
cachedMetadata.packageMetadata[packageName] = PackageMetadata(23)
every { context.openFileInput(METADATA_CACHE_FILE) } returns cacheInputStream
every { cacheInputStream.available() } returns byteArray.size andThen 0
every { cacheInputStream.read(byteArray) } returns -1
every { metadataReader.decode(ByteArray(0)) } returns cachedMetadata
every { clock.time() } returns time
every { metadataWriter.write(cachedMetadata, storageOutputStream) } just Runs
expectWriteToCache(cachedMetadata)
manager.onPackageBackedUp(packageName, storageOutputStream)
assertEquals(time, manager.getLastBackupTime())
// TODO also assert updated PackageMetadata once possible
}
private fun expectWriteToCache(metadata: BackupMetadata) {
every { metadataWriter.encode(metadata) } returns encodedMetadata
every { context.openFileOutput(METADATA_CACHE_FILE, MODE_PRIVATE) } returns cacheOutputStream
every { cacheOutputStream.write(encodedMetadata) } just Runs
}
}

View file

@ -122,7 +122,7 @@ class MetadataReaderTest {
assertNull(packageMetadata.signatures)
}
private fun getMetadata(packageMetadata: Map<String, PackageMetadata> = HashMap()): BackupMetadata {
private fun getMetadata(packageMetadata: HashMap<String, PackageMetadata> = HashMap()): BackupMetadata {
return BackupMetadata(
version = 1.toByte(),
token = Random.nextLong(),

View file

@ -66,7 +66,7 @@ internal class MetadataWriterDecoderTest {
assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
}
private fun getMetadata(packageMetadata: Map<String, PackageMetadata> = HashMap()): BackupMetadata {
private fun getMetadata(packageMetadata: HashMap<String, PackageMetadata> = HashMap()): BackupMetadata {
return BackupMetadata(
version = Random.nextBytes(1)[0],
token = Random.nextLong(),

View file

@ -16,7 +16,6 @@ import com.stevesoltys.seedvault.header.HeaderReaderImpl
import com.stevesoltys.seedvault.header.HeaderWriterImpl
import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
import com.stevesoltys.seedvault.metadata.MetadataWriterImpl
import com.stevesoltys.seedvault.transport.backup.*
import com.stevesoltys.seedvault.transport.restore.*
import io.mockk.*
@ -35,7 +34,6 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val headerWriter = HeaderWriterImpl()
private val headerReader = HeaderReaderImpl()
private val cryptoImpl = CryptoImpl(cipherFactory, headerWriter, headerReader)
private val metadataWriter = MetadataWriterImpl(cryptoImpl)
private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val backupPlugin = mockk<BackupPlugin>()
@ -44,20 +42,21 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val fullBackupPlugin = mockk<FullBackupPlugin>()
private val fullBackup = FullBackup(fullBackupPlugin, inputFactory, headerWriter, cryptoImpl)
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataWriter, settingsManager, notificationManager)
private val backup = BackupCoordinator(context, backupPlugin, kvBackup, fullBackup, metadataManager, settingsManager, notificationManager)
private val restorePlugin = mockk<RestorePlugin>()
private val kvRestorePlugin = mockk<KVRestorePlugin>()
private val kvRestore = KVRestore(kvRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val fullRestorePlugin = mockk<FullRestorePlugin>()
private val fullRestore = FullRestore(fullRestorePlugin, outputFactory, headerReader, cryptoImpl)
private val restore = RestoreCoordinator(settingsManager, restorePlugin, kvRestore, fullRestore, metadataReader)
private val restore = RestoreCoordinator(metadataManager, restorePlugin, kvRestore, fullRestore, metadataReader)
private val backupDataInput = mockk<BackupDataInput>()
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
private val token = Random.nextLong()
private val appData = ByteArray(42).apply { Random.nextBytes(this) }
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
private val metadataOutputStream = ByteArrayOutputStream()
private val key = "RestoreKey"
private val key64 = key.encodeBase64()
private val key2 = "RestoreKey2"
@ -92,7 +91,8 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData2.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2
every { settingsManager.saveNewBackupTime() } just Runs
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
// start and finish K/V backup
assertEquals(TRANSPORT_OK, backup.performIncrementalBackup(packageInfo, fileDescriptor, 0))
@ -179,7 +179,8 @@ internal class CoordinatorIntegrationTest : TransportTest() {
every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { settingsManager.saveNewBackupTime() } just Runs
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
// perform backup to output stream
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
import io.mockk.every
import io.mockk.mockk
@ -16,6 +17,7 @@ abstract class TransportTest {
protected val crypto = mockk<Crypto>()
protected val settingsManager = mockk<SettingsManager>()
protected val metadataManager = mockk<MetadataManager>()
protected val context = mockk<Context>(relaxed = true)
protected val packageInfo = PackageInfo().apply { packageName = "org.example" }

View file

@ -6,7 +6,6 @@ import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.MetadataWriter
import com.stevesoltys.seedvault.settings.Storage
import io.mockk.Runs
import io.mockk.every
@ -24,18 +23,17 @@ internal class BackupCoordinatorTest: BackupTest() {
private val plugin = mockk<BackupPlugin>()
private val kv = mockk<KVBackup>()
private val full = mockk<FullBackup>()
private val metadataWriter = mockk<MetadataWriter>()
private val notificationManager = mockk<BackupNotificationManager>()
private val backup = BackupCoordinator(context, plugin, kv, full, metadataWriter, settingsManager, notificationManager)
private val backup = BackupCoordinator(context, plugin, kv, full, metadataManager, settingsManager, notificationManager)
private val metadataOutputStream = mockk<OutputStream>()
@Test
fun `device initialization succeeds and delegates to plugin`() {
every { plugin.initializeDevice() } just Runs
every { settingsManager.getBackupToken() } returns token
expectWritingMetadata(token)
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onDeviceInitialization(metadataOutputStream) } just Runs
every { kv.hasState() } returns false
every { full.hasState() } returns false
@ -145,9 +143,4 @@ internal class BackupCoordinatorTest: BackupTest() {
assertEquals(result, backup.finishBackup())
}
private fun expectWritingMetadata(token: Long = this.token) {
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataWriter.write(metadataOutputStream, token) } just Runs
}
}

View file

@ -27,7 +27,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
private val full = mockk<FullRestore>()
private val metadataReader = mockk<MetadataReader>()
private val restore = RestoreCoordinator(settingsManager, plugin, kv, full, metadataReader)
private val restore = RestoreCoordinator(metadataManager, plugin, kv, full, metadataReader)
private val token = Random.nextLong()
private val inputStream = mockk<InputStream>()
@ -57,7 +57,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test
fun `getCurrentRestoreSet() delegates to plugin`() {
every { settingsManager.getBackupToken() } returns token
every { metadataManager.getBackupToken() } returns token
assertEquals(token, restore.getCurrentRestoreSet())
}