Request @pm@ backup after initialization

to avoid a 2nd restore set being used.

This also changes the initialization behavior to only create the restore set folder and upload the metadata only when we actually need to. This way, double inits are not creating new restore sets on the backup destination.
This commit is contained in:
Torsten Grote 2024-02-26 16:07:16 -03:00
parent 23787a373e
commit 2da989971b
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
11 changed files with 118 additions and 87 deletions

View file

@ -47,7 +47,7 @@ class KoinInstrumentationTestApp : App() {
viewModel { viewModel {
currentBackupStorageViewModel = currentBackupStorageViewModel =
spyk(BackupStorageViewModel(context, get(), get(), get())) spyk(BackupStorageViewModel(context, get(), get(), get(), get()))
currentBackupStorageViewModel!! currentBackupStorageViewModel!!
} }

View file

@ -59,7 +59,7 @@ open class App : Application() {
viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get()) }
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
viewModel { BackupStorageViewModel(this@App, get(), get(), get()) } viewModel { BackupStorageViewModel(this@App, get(), get(), get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get()) }
viewModel { FileSelectionViewModel(this@App, get()) } viewModel { FileSelectionViewModel(this@App, get()) }

View file

@ -59,14 +59,15 @@ internal class MetadataManager(
/** /**
* Call this when initializing a new device. * Call this when initializing a new device.
* *
* Existing [BackupMetadata] will be cleared, use the given new token, * Existing [BackupMetadata] will be cleared
* and written encrypted to the given [OutputStream] as well as the internal cache. * and new metadata with the given [token] will be written to the internal cache
* with a fresh salt.
*/ */
@Synchronized @Synchronized
@Throws(IOException::class) @Throws(IOException::class)
fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) { fun onDeviceInitialization(token: Long) {
val salt = crypto.getRandomBytes(METADATA_SALT_SIZE).encodeBase64() val salt = crypto.getRandomBytes(METADATA_SALT_SIZE).encodeBase64()
modifyMetadata(metadataOutputStream) { modifyCachedMetadata {
metadata = BackupMetadata(token = token, salt = salt) metadata = BackupMetadata(token = token, salt = salt)
} }
} }

View file

@ -35,22 +35,13 @@ internal class DocumentsProviderStoragePlugin(
override suspend fun startNewRestoreSet(token: Long) { override suspend fun startNewRestoreSet(token: Long) {
// reset current storage // reset current storage
storage.reset(token) storage.reset(token)
// get or create root backup dir
storage.rootBackupDir ?: throw IOException()
} }
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun initializeDevice() { override suspend fun initializeDevice() {
// wipe existing data
storage.getSetDir()?.deleteContents(context)
// reset storage without new token, so folders get recreated // reset storage without new token, so folders get recreated
// otherwise stale DocumentFiles will hang around // otherwise stale DocumentFiles will hang around
storage.reset(null) storage.reset(null)
// create backup folders
storage.currentSetDir ?: throw IOException()
} }
@Throws(IOException::class) @Throws(IOException::class)

View file

@ -86,12 +86,13 @@ internal class BackupCoordinator(
* @return the token of the new [RestoreSet]. * @return the token of the new [RestoreSet].
*/ */
@Throws(IOException::class) @Throws(IOException::class)
private suspend fun startNewRestoreSet(): Long { private suspend fun startNewRestoreSet() {
val token = clock.time() val token = clock.time()
Log.i(TAG, "Starting new RestoreSet with token $token...") Log.i(TAG, "Starting new RestoreSet with token $token...")
settingsManager.setNewToken(token) settingsManager.setNewToken(token)
plugin.startNewRestoreSet(token) plugin.startNewRestoreSet(token)
return token Log.d(TAG, "Resetting backup metadata...")
metadataManager.onDeviceInitialization(token)
} }
/** /**
@ -115,18 +116,14 @@ internal class BackupCoordinator(
suspend fun initializeDevice(): Int = try { suspend fun initializeDevice(): Int = try {
// we don't respect the intended system behavior here by always starting a new [RestoreSet] // we don't respect the intended system behavior here by always starting a new [RestoreSet]
// instead of simply deleting the current one // instead of simply deleting the current one
val token = startNewRestoreSet() startNewRestoreSet()
Log.i(TAG, "Initialize Device!") Log.i(TAG, "Initialize Device!")
plugin.initializeDevice() plugin.initializeDevice()
Log.d(TAG, "Resetting backup metadata for token $token...")
plugin.getMetadataOutputStream(token).use {
metadataManager.onDeviceInitialization(token, it)
}
// [finishBackup] will only be called when we return [TRANSPORT_OK] here // [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully // so we remember that we initialized successfully
state.calledInitialize = true state.calledInitialize = true
TRANSPORT_OK TRANSPORT_OK
} catch (e: IOException) { } catch (e: Exception) {
Log.e(TAG, "Error initializing device", e) Log.e(TAG, "Error initializing device", e)
// Show error notification if we needed init or were ready for backups // Show error notification if we needed init or were ready for backups
if (metadataManager.requiresInit || settingsManager.canDoBackupNow()) nm.onBackupError() if (metadataManager.requiresInit || settingsManager.canDoBackupNow()) nm.onBackupError()
@ -222,14 +219,11 @@ internal class BackupCoordinator(
state.cancelReason = UNKNOWN_ERROR state.cancelReason = UNKNOWN_ERROR
if (metadataManager.requiresInit) { if (metadataManager.requiresInit) {
Log.w(TAG, "Metadata requires re-init!") Log.w(TAG, "Metadata requires re-init!")
// start a new restore set to upgrade from legacy format // Tell the system that we are not initialized, it will initialize us afterwards.
// by starting a clean backup with all files using the new version // This will start a new restore set to upgrade from legacy format
try { // by starting a clean backup with all files using the new version.
startNewRestoreSet() //
} catch (e: IOException) { // This causes a backup error, but things should go back to normal afterwards.
Log.e(TAG, "Error starting new restore set", e)
}
// this causes a backup error, but things should go back to normal afterwards
return TRANSPORT_NOT_INITIALIZED return TRANSPORT_NOT_INITIALIZED
} }
val token = settingsManager.getToken() ?: error("no token in performFullBackup") val token = settingsManager.getToken() ?: error("no token in performFullBackup")

View file

@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupProgress
import android.app.backup.IBackupManager
import android.app.backup.IBackupObserver
import android.os.UserHandle
import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
class BackupInitializer(
private val backupManager: IBackupManager,
) {
companion object {
private val TAG = BackupInitializer::class.simpleName
}
fun initialize(onError: () -> Unit, onSuccess: () -> Unit) {
val observer = BackupObserver("Initialization", onError) {
// After successful initialization, we request a @pm@ backup right away,
// because if this finds empty state, it asks us to do another initialization.
// And then we end up with yet another restore set token.
// Since we want the final token as soon as possible, we need to get it here.
Log.d(TAG, "Requesting initial $MAGIC_PACKAGE_MANAGER backup...")
backupManager.requestBackup(
arrayOf(MAGIC_PACKAGE_MANAGER),
BackupObserver("Initial backup of @pm@", onError, onSuccess),
BackupMonitor(),
0,
)
}
backupManager.initializeTransportsForUser(
UserHandle.myUserId(),
arrayOf(TRANSPORT_ID),
observer,
)
}
@WorkerThread
private inner class BackupObserver(
private val operation: String,
private val onError: () -> Unit,
private val onSuccess: () -> Unit,
) : IBackupObserver.Stub() {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
// noop
}
override fun onResult(target: String, status: Int) {
// noop
}
override fun backupFinished(status: Int) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "$operation finished. Status: $status")
}
if (status == 0) {
onSuccess()
} else {
onError()
}
}
}
}

View file

@ -4,6 +4,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val backupModule = module { val backupModule = module {
single { BackupInitializer(get()) }
single { InputFactory() } single { InputFactory() }
single { single {
PackageService( PackageService(

View file

@ -1,7 +1,6 @@
package com.stevesoltys.seedvault.ui.recoverycode package com.stevesoltys.seedvault.ui.recoverycode
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.os.UserHandle
import android.util.Log import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import cash.z.ecc.android.bip39.Mnemonics import cash.z.ecc.android.bip39.Mnemonics
@ -12,8 +11,7 @@ import cash.z.ecc.android.bip39.toSeed
import com.stevesoltys.seedvault.App import com.stevesoltys.seedvault.App
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.transport.TRANSPORT_ID import com.stevesoltys.seedvault.transport.backup.BackupInitializer
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
@ -32,7 +30,7 @@ internal class RecoveryCodeViewModel(
private val crypto: Crypto, private val crypto: Crypto,
private val keyManager: KeyManager, private val keyManager: KeyManager,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val backupCoordinator: BackupCoordinator, private val backupInitializer: BackupInitializer,
private val notificationManager: BackupNotificationManager, private val notificationManager: BackupNotificationManager,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
) : AndroidViewModel(app) { ) : AndroidViewModel(app) {
@ -109,11 +107,9 @@ internal class RecoveryCodeViewModel(
storageBackup.clearCache() storageBackup.clearCache()
try { try {
// initialize the new location // initialize the new location
if (backupManager.isBackupEnabled) backupManager.initializeTransportsForUser( if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
UserHandle.myUserId(), // no-op
arrayOf(TRANSPORT_ID), }
null
)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error starting new RestoreSet", e) Log.e(TAG, "Error starting new RestoreSet", e)
} }

View file

@ -1,20 +1,16 @@
package com.stevesoltys.seedvault.ui.storage package com.stevesoltys.seedvault.ui.storage
import android.app.Application import android.app.Application
import android.app.backup.BackupProgress
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.app.backup.IBackupObserver
import android.app.job.JobInfo import android.app.job.JobInfo
import android.net.Uri import android.net.Uri
import android.os.UserHandle
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.storage.StorageBackupJobService
import com.stevesoltys.seedvault.transport.TRANSPORT_ID import com.stevesoltys.seedvault.transport.backup.BackupInitializer
import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.AppBackupWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -28,6 +24,7 @@ private val TAG = BackupStorageViewModel::class.java.simpleName
internal class BackupStorageViewModel( internal class BackupStorageViewModel(
private val app: Application, private val app: Application,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val backupInitializer: BackupInitializer,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
settingsManager: SettingsManager, settingsManager: SettingsManager,
) : StorageViewModel(app, settingsManager) { ) : StorageViewModel(app, settingsManager) {
@ -52,13 +49,23 @@ internal class BackupStorageViewModel(
storageBackup.clearCache() storageBackup.clearCache()
try { try {
// initialize the new location (if backups are enabled) // initialize the new location (if backups are enabled)
if (backupManager.isBackupEnabled) backupManager.initializeTransportsForUser( if (backupManager.isBackupEnabled) {
UserHandle.myUserId(), val onError = {
arrayOf(TRANSPORT_ID), Log.e(TAG, "Error starting new RestoreSet")
// if storage is on USB and this is not SetupWizard, do a backup right away onInitializationError()
InitializationObserver(isUsb && !isSetupWizard) }
) else { backupInitializer.initialize(onError) {
InitializationObserver(false).backupFinished(0) val requestBackup = isUsb && !isSetupWizard
if (requestBackup) {
Log.i(TAG, "Requesting a backup now, because we use USB storage")
AppBackupWorker.scheduleNow(app, reschedule = false)
}
// notify the UI that the location has been set
mLocationChecked.postEvent(LocationResult())
}
} else {
// notify the UI that the location has been set
mLocationChecked.postEvent(LocationResult())
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error starting new RestoreSet", e) Log.e(TAG, "Error starting new RestoreSet", e)
@ -90,35 +97,6 @@ internal class BackupStorageViewModel(
BackupJobService.cancelJob(app) BackupJobService.cancelJob(app)
} }
@WorkerThread
private inner class InitializationObserver(val requestBackup: Boolean) :
IBackupObserver.Stub() {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
// noop
}
override fun onResult(target: String, status: Int) {
// noop
}
override fun backupFinished(status: Int) {
if (Log.isLoggable(TAG, Log.INFO)) {
Log.i(TAG, "Initialization finished. Status: $status")
}
if (status == 0) {
// notify the UI that the location has been set
mLocationChecked.postEvent(LocationResult())
if (requestBackup) {
val isUsb = settingsManager.getStorage()?.isUsb ?: false
AppBackupWorker.scheduleNow(app, reschedule = !isUsb)
}
} else {
// notify the UI that the location was invalid
onInitializationError()
}
}
}
private fun onInitializationError() { private fun onInitializationError() {
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error) val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
mLocationChecked.postEvent(LocationResult(errorMsg)) mLocationChecked.postEvent(LocationResult(errorMsg))

View file

@ -100,7 +100,7 @@ class MetadataManagerTest {
expectReadFromCache() expectReadFromCache()
expectModifyMetadata(initialMetadata) expectModifyMetadata(initialMetadata)
manager.onDeviceInitialization(token, storageOutputStream) manager.onDeviceInitialization(token)
assertEquals(token, manager.getBackupToken()) assertEquals(token, manager.getBackupToken())
assertEquals(0L, manager.getLastBackupTime()) assertEquals(0L, manager.getLastBackupTime())

View file

@ -69,22 +69,18 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `device initialization succeeds and delegates to plugin`() = runBlocking { fun `device initialization succeeds and delegates to plugin`() = runBlocking {
expectStartNewRestoreSet() expectStartNewRestoreSet()
coEvery { plugin.initializeDevice() } just Runs coEvery { plugin.initializeDevice() } just Runs
coEvery { plugin.getOutputStream(token, FILE_BACKUP_METADATA) } returns metadataOutputStream
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
every { metadataOutputStream.close() } just Runs
assertEquals(TRANSPORT_OK, backup.initializeDevice()) assertEquals(TRANSPORT_OK, backup.initializeDevice())
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
verify { metadataOutputStream.close() }
} }
private suspend fun expectStartNewRestoreSet() { private suspend fun expectStartNewRestoreSet() {
every { clock.time() } returns token every { clock.time() } returns token
every { settingsManager.setNewToken(token) } just Runs every { settingsManager.setNewToken(token) } just Runs
coEvery { plugin.startNewRestoreSet(token) } just Runs coEvery { plugin.startNewRestoreSet(token) } just Runs
every { metadataManager.onDeviceInitialization(token) } just Runs
} }
@Test @Test
@ -136,6 +132,7 @@ internal class BackupCoordinatorTest : BackupTest() {
every { clock.time() } returns token + 1 every { clock.time() } returns token + 1
every { settingsManager.setNewToken(token + 1) } just Runs every { settingsManager.setNewToken(token + 1) } just Runs
coEvery { plugin.startNewRestoreSet(token + 1) } just Runs coEvery { plugin.startNewRestoreSet(token + 1) } just Runs
every { metadataManager.onDeviceInitialization(token + 1) } just Runs
every { data.close() } just Runs every { data.close() } just Runs