Clean up backup transport initialization logic

This commit makes creating new RestoreSets explicit.
Initializing a backup transport now actually cleans its data as the AOSP
documentation demands. This should be fine as we usually do a fresh
backup after a new initialization.
Contrary to before, an initialization does not create new RestoreSets
anymore, but works within the existing set. For now, only manually
choosing a new storage location creates a new RestoreSet.
This commit is contained in:
Torsten Grote 2020-08-31 17:20:01 -03:00 committed by Chirayu Desai
parent 80187c8c70
commit 1b9a4feddd
26 changed files with 454 additions and 195 deletions

View file

@ -0,0 +1,48 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Instrumentation Tests" type="AndroidTestRunConfigurationType" factoryName="Android Instrumented Tests">
<module name="app" />
<option name="TESTING_TYPE" value="0" />
<option name="METHOD_NAME" value="" />
<option name="CLASS_NAME" value="" />
<option name="PACKAGE_NAME" value="" />
<option name="INSTRUMENTATION_RUNNER_CLASS" value="" />
<option name="EXTRA_OPTIONS" value="-e notAnnotation androidx.test.filters.LargeTest" />
<option name="INCLUDE_GRADLE_EXTRA_OPTIONS" value="true" />
<option name="CLEAR_LOGCAT" value="false" />
<option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
<option name="SKIP_NOOP_APK_INSTALLATIONS" value="true" />
<option name="FORCE_STOP_RUNNING_APP" value="true" />
<option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
<option name="DEBUGGER_TYPE" value="Auto" />
<Auto>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Auto>
<Hybrid>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Hybrid>
<Java />
<Native>
<option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
<option name="SHOW_STATIC_VARS" value="true" />
<option name="WORKING_DIR" value="" />
<option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
<option name="SHOW_OPTIMIZED_WARNING" value="true" />
</Native>
<Profilers>
<option name="ADVANCED_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
<option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Sample Java Methods" />
</Profilers>
<method v="2">
<option name="Android.Gradle.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>

View file

@ -1,8 +1,8 @@
package com.stevesoltys.seedvault package com.stevesoltys.seedvault
import android.util.Log import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest import androidx.test.filters.LargeTest
import androidx.test.runner.AndroidJUnit4
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue

View file

@ -3,8 +3,11 @@ package com.stevesoltys.seedvault
import androidx.test.core.content.pm.PackageInfoBuilder import androidx.test.core.content.pm.PackageInfoBuilder
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderBackupPlugin import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderBackupPlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderFullBackup
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderFullRestorePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderKVBackup
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderKVRestorePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderRestorePlugin import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderRestorePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH
@ -12,6 +15,10 @@ import com.stevesoltys.seedvault.plugins.saf.MAX_KEY_LENGTH_NEXTCLOUD
import com.stevesoltys.seedvault.plugins.saf.deleteContents import com.stevesoltys.seedvault.plugins.saf.deleteContents
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.BackupPlugin import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -35,27 +42,41 @@ import kotlin.random.Random
class PluginTest : KoinComponent { class PluginTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val metadataManager: MetadataManager by inject()
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val mockedSettingsManager: SettingsManager = mockk() private val mockedSettingsManager: SettingsManager = mockk()
private val storage = DocumentsStorage(context, metadataManager, mockedSettingsManager) private val storage = DocumentsStorage(context, mockedSettingsManager)
private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(context, storage)
private val restorePlugin: RestorePlugin = DocumentsProviderRestorePlugin(context, storage) private val kvBackupPlugin: KVBackupPlugin = DocumentsProviderKVBackup(context, storage)
private val fullBackupPlugin: FullBackupPlugin = DocumentsProviderFullBackup(context, storage)
private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(
context,
storage,
kvBackupPlugin,
fullBackupPlugin
)
private val kvRestorePlugin: KVRestorePlugin =
DocumentsProviderKVRestorePlugin(context, storage)
private val fullRestorePlugin: FullRestorePlugin =
DocumentsProviderFullRestorePlugin(context, storage)
private val restorePlugin: RestorePlugin =
DocumentsProviderRestorePlugin(context, storage, kvRestorePlugin, fullRestorePlugin)
private val token = Random.nextLong() private val token = Random.nextLong()
private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build() private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
private val packageInfo2 = PackageInfoBuilder.newBuilder().setPackageName("net.example").build() private val packageInfo2 = PackageInfoBuilder.newBuilder().setPackageName("net.example").build()
@Before @Before
fun setup() { fun setup() = runBlocking {
every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage() every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage()
storage.rootBackupDir?.deleteContents() storage.rootBackupDir?.deleteContents(context)
?: error("Select a storage location in the app first!") ?: error("Select a storage location in the app first!")
} }
@After @After
fun tearDown() { fun tearDown() = runBlocking {
storage.rootBackupDir?.deleteContents() storage.rootBackupDir?.deleteContents(context)
Unit
} }
@Test @Test
@ -77,13 +98,12 @@ class PluginTest : KoinComponent {
val uri = settingsManager.getStorage()?.getDocumentFile(context)?.uri ?: error("no storage") val uri = settingsManager.getStorage()?.getDocumentFile(context)?.uri ?: error("no storage")
assertFalse(restorePlugin.hasBackup(uri)) assertFalse(restorePlugin.hasBackup(uri))
// define storage changing state for later // prepare returned tokens requested when initializing device
every { every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
mockedSettingsManager.getAndResetIsStorageChanging()
} returns true andThen true andThen false
// device needs initialization, because new and storage is changing // start new restore set and initialize device afterwards
assertTrue(backupPlugin.initializeDevice(newToken = token)) backupPlugin.startNewRestoreSet(token)
backupPlugin.initializeDevice()
// write metadata (needed for backup to be recognized) // write metadata (needed for backup to be recognized)
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray()) backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
@ -92,22 +112,29 @@ class PluginTest : KoinComponent {
assertEquals(1, restorePlugin.getAvailableBackups()?.toList()?.size) assertEquals(1, restorePlugin.getAvailableBackups()?.toList()?.size)
assertTrue(restorePlugin.hasBackup(uri)) assertTrue(restorePlugin.hasBackup(uri))
// initializing again (while changing storage) does add a restore set // initializing again (with another restore set) does add a restore set
assertTrue(backupPlugin.initializeDevice(newToken = token + 1)) backupPlugin.startNewRestoreSet(token + 1)
backupPlugin.initializeDevice()
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray()) backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size) assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
assertTrue(restorePlugin.hasBackup(uri)) assertTrue(restorePlugin.hasBackup(uri))
// initializing again (without changing storage) doesn't change number of restore sets // initializing again (without new restore set) doesn't change number of restore sets
assertFalse(backupPlugin.initializeDevice(newToken = token + 2)) backupPlugin.initializeDevice()
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray()) backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size) assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
// ensure that the new backup dirs exist
assertTrue(storage.currentKvBackupDir!!.exists())
assertTrue(storage.currentFullBackupDir!!.exists())
} }
@Test @Test
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) { fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true andThen false every { mockedSettingsManager.getToken() } returns token
assertTrue(backupPlugin.initializeDevice(newToken = token))
backupPlugin.startNewRestoreSet(token)
backupPlugin.initializeDevice()
// write metadata // write metadata
val metadata = getRandomByteArray() val metadata = getRandomByteArray()
@ -124,7 +151,8 @@ class PluginTest : KoinComponent {
assertReadEquals(metadata, availableBackups[0].inputStream) assertReadEquals(metadata, availableBackups[0].inputStream)
// initializing again (without changing storage) keeps restore set with same token // initializing again (without changing storage) keeps restore set with same token
assertFalse(backupPlugin.initializeDevice(newToken = token + 1)) backupPlugin.initializeDevice()
backupPlugin.getMetadataOutputStream().writeAndClose(metadata)
availableBackups = restorePlugin.getAvailableBackups()?.toList() availableBackups = restorePlugin.getAvailableBackups()?.toList()
check(availableBackups != null) check(availableBackups != null)
assertEquals(1, availableBackups.size) assertEquals(1, availableBackups.size)
@ -311,8 +339,8 @@ class PluginTest : KoinComponent {
} }
private fun initStorage(token: Long) = runBlocking { private fun initStorage(token: Long) = runBlocking {
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true every { mockedSettingsManager.getToken() } returns token
assertTrue(backupPlugin.initializeDevice(newToken = token)) backupPlugin.initializeDevice()
} }
private fun isNextcloud(): Boolean { private fun isNextcloud(): Boolean {

View file

@ -45,7 +45,7 @@ class DocumentsStorageTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val metadataManager by inject<MetadataManager>() private val metadataManager by inject<MetadataManager>()
private val settingsManager by inject<SettingsManager>() private val settingsManager by inject<SettingsManager>()
private val storage = DocumentsStorage(context, metadataManager, settingsManager) private val storage = DocumentsStorage(context, settingsManager)
private val filename = getRandomBase64() private val filename = getRandomBase64()
private lateinit var file: DocumentFile private lateinit var file: DocumentFile
@ -96,6 +96,7 @@ class DocumentsStorageTest : KoinComponent {
val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!) val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!)
assertNotNull(foundFile) assertNotNull(foundFile)
assertEquals(filename, foundFile!!.name) assertEquals(filename, foundFile!!.name)
assertEquals(storage.rootBackupDir!!.uri, foundFile.parentFile?.uri)
} }
@Test @Test

View file

@ -8,6 +8,7 @@ import android.os.Build
import android.os.ServiceManager.getService import android.os.ServiceManager.getService
import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.metadataModule import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
import com.stevesoltys.seedvault.restore.RestoreViewModel import com.stevesoltys.seedvault.restore.RestoreViewModel
@ -19,6 +20,7 @@ import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel import com.stevesoltys.seedvault.ui.storage.RestoreStorageViewModel
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.androidx.viewmodel.dsl.viewModel
@ -39,7 +41,7 @@ class App : Application() {
viewModel { SettingsViewModel(this@App, get(), get(), get(), get()) } viewModel { SettingsViewModel(this@App, get(), get(), get(), get()) }
viewModel { RecoveryCodeViewModel(this@App, get()) } viewModel { RecoveryCodeViewModel(this@App, get()) }
viewModel { BackupStorageViewModel(this@App, get(), get()) } viewModel { BackupStorageViewModel(this@App, get(), get(), get()) }
viewModel { RestoreStorageViewModel(this@App, get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) } viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
} }
@ -49,7 +51,8 @@ class App : Application() {
startKoin { startKoin {
androidLogger() androidLogger()
androidContext(this@App) androidContext(this@App)
modules(listOf( modules(
listOf(
cryptoModule, cryptoModule,
headerModule, headerModule,
metadataModule, metadataModule,
@ -57,7 +60,25 @@ class App : Application() {
backupModule, backupModule,
restoreModule, restoreModule,
appModule appModule
)) )
)
}
migrateTokenFromMetadataToSettingsManager()
}
private val settingsManager: SettingsManager by inject()
private val metadataManager: MetadataManager by inject()
/**
* The responsibility for the current token was moved to the [SettingsManager]
* in the end of 2020.
* This method migrates the token for existing installs and can be removed
* after sufficient time has passed.
*/
private fun migrateTokenFromMetadataToSettingsManager() {
val token = metadataManager.getBackupToken()
if (token != 0L && settingsManager.getToken() == null) {
settingsManager.setNewToken(token)
} }
} }

View file

@ -178,6 +178,7 @@ class MetadataManager(
* If the token is 0L, it is not yet initialized and must not be used for anything. * If the token is 0L, it is not yet initialized and must not be used for anything.
*/ */
@Synchronized @Synchronized
@Deprecated("Responsibility for current token moved to SettingsManager", ReplaceWith("settingsManager.getToken()"))
fun getBackupToken(): Long = metadata.token fun getBackupToken(): Long = metadata.token
/** /**

View file

@ -14,41 +14,34 @@ private const val MIME_TYPE_APK = "application/vnd.android.package-archive"
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderBackupPlugin( internal class DocumentsProviderBackupPlugin(
private val context: Context, private val context: Context,
private val storage: DocumentsStorage private val storage: DocumentsStorage,
override val kvBackupPlugin: KVBackupPlugin,
override val fullBackupPlugin: FullBackupPlugin
) : BackupPlugin { ) : BackupPlugin {
private val packageManager: PackageManager = context.packageManager private val packageManager: PackageManager = context.packageManager
override val kvBackupPlugin: KVBackupPlugin by lazy {
DocumentsProviderKVBackup(storage, context)
}
override val fullBackupPlugin: FullBackupPlugin by lazy {
DocumentsProviderFullBackup(storage, context)
}
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun initializeDevice(newToken: Long): Boolean { override suspend fun startNewRestoreSet(token: Long) {
// check if storage is already initialized
if (storage.isInitialized()) return false
// TODO consider not creating new RestoreSets, but continue working within the existing one.
// reset current storage // reset current storage
storage.reset(newToken) storage.reset(token)
// get or create root backup dir // get or create root backup dir
storage.rootBackupDir ?: throw IOException() storage.rootBackupDir ?: throw IOException()
}
@Throws(IOException::class)
override suspend fun initializeDevice() {
// wipe existing data
storage.getSetDir()?.deleteContents(context)
// reset storage without new token, so folders get recreated
// otherwise stale DocumentFiles will hang around
storage.reset(null)
// create backup folders // create backup folders
val kvDir = storage.currentKvBackupDir storage.currentKvBackupDir ?: throw IOException()
val fullDir = storage.currentFullBackupDir storage.currentFullBackupDir ?: throw IOException()
// wipe existing data
storage.getSetDir()?.findFileBlocking(context, FILE_BACKUP_METADATA)?.delete()
kvDir?.deleteContents()
fullDir?.deleteContents()
return true
} }
@Throws(IOException::class) @Throws(IOException::class)

View file

@ -12,8 +12,8 @@ private val TAG = DocumentsProviderFullBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderFullBackup( internal class DocumentsProviderFullBackup(
private val storage: DocumentsStorage, private val context: Context,
private val context: Context private val storage: DocumentsStorage
) : FullBackupPlugin { ) : FullBackupPlugin {
override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP

View file

@ -14,8 +14,8 @@ const val MAX_KEY_LENGTH_NEXTCLOUD = 225
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderKVBackup( internal class DocumentsProviderKVBackup(
private val storage: DocumentsStorage, private val context: Context,
private val context: Context private val storage: DocumentsStorage
) : KVBackupPlugin { ) : KVBackupPlugin {
private var packageFile: DocumentFile? = null private var packageFile: DocumentFile? = null
@ -27,7 +27,7 @@ internal class DocumentsProviderKVBackup(
val packageFile = val packageFile =
storage.currentKvBackupDir?.findFileBlocking(context, packageInfo.packageName) storage.currentKvBackupDir?.findFileBlocking(context, packageInfo.packageName)
?: return false ?: return false
return packageFile.listFiles().isNotEmpty() return packageFile.listFilesBlocking(context).isNotEmpty()
} }
@Throws(IOException::class) @Throws(IOException::class)

View file

@ -26,10 +26,11 @@ internal class DocumentsProviderKVRestorePlugin(
} }
} }
override fun listRecords(token: Long, packageInfo: PackageInfo): List<String> { @Throws(IOException::class)
override suspend fun listRecords(token: Long, packageInfo: PackageInfo): List<String> {
val packageDir = this.packageDir ?: throw AssertionError() val packageDir = this.packageDir ?: throw AssertionError()
packageDir.assertRightFile(packageInfo) packageDir.assertRightFile(packageInfo)
return packageDir.listFiles() return packageDir.listFilesBlocking(context)
.filter { file -> file.name != null } .filter { file -> file.name != null }
.map { file -> file.name!! } .map { file -> file.name!! }
} }

View file

@ -1,12 +1,22 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import com.stevesoltys.seedvault.transport.backup.BackupPlugin import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import com.stevesoltys.seedvault.transport.restore.FullRestorePlugin
import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val documentsProviderModule = module { val documentsProviderModule = module {
single { DocumentsStorage(androidContext(), get(), get()) } single { DocumentsStorage(androidContext(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(androidContext(), get()) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) } single<KVBackupPlugin> { DocumentsProviderKVBackup(androidContext(), get()) }
single<FullBackupPlugin> { DocumentsProviderFullBackup(androidContext(), get()) }
single<BackupPlugin> { DocumentsProviderBackupPlugin(androidContext(), get(), get(), get()) }
single<KVRestorePlugin> { DocumentsProviderKVRestorePlugin(androidContext(), get()) }
single<FullRestorePlugin> { DocumentsProviderFullRestorePlugin(androidContext(), get()) }
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get(), get(), get()) }
} }

View file

@ -19,17 +19,11 @@ private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext") // all methods do I/O @Suppress("BlockingMethodInNonBlockingContext") // all methods do I/O
internal class DocumentsProviderRestorePlugin( internal class DocumentsProviderRestorePlugin(
private val context: Context, private val context: Context,
private val storage: DocumentsStorage private val storage: DocumentsStorage,
override val kvRestorePlugin: KVRestorePlugin,
override val fullRestorePlugin: FullRestorePlugin
) : RestorePlugin { ) : RestorePlugin {
override val kvRestorePlugin: KVRestorePlugin by lazy {
DocumentsProviderKVRestorePlugin(context, storage)
}
override val fullRestorePlugin: FullRestorePlugin by lazy {
DocumentsProviderFullRestorePlugin(context, storage)
}
@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()

View file

@ -1,4 +1,4 @@
@file:Suppress("EXPERIMENTAL_API_USAGE", "BlockingMethodInNonBlockingContext") @file:Suppress("BlockingMethodInNonBlockingContext")
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
@ -9,17 +9,13 @@ import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.os.FileUtils.closeQuietly import android.os.FileUtils.closeQuietly
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
import android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE
import android.provider.DocumentsContract.Document.MIME_TYPE_DIR
import android.provider.DocumentsContract.EXTRA_LOADING import android.provider.DocumentsContract.EXTRA_LOADING
import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree
import android.provider.DocumentsContract.buildDocumentUriUsingTree import android.provider.DocumentsContract.buildDocumentUriUsingTree
import android.provider.DocumentsContract.buildTreeDocumentUri
import android.provider.DocumentsContract.getDocumentId import android.provider.DocumentsContract.getDocumentId
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
@ -42,7 +38,6 @@ private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage( internal class DocumentsStorage(
private val context: Context, private val context: Context,
private val metadataManager: MetadataManager,
private val settingsManager: SettingsManager private val settingsManager: SettingsManager
) { ) {
@ -73,9 +68,9 @@ internal class DocumentsStorage(
field field
} }
private var currentToken: Long = 0L private var currentToken: Long? = null
get() { get() {
if (field == 0L) field = metadataManager.getBackupToken() if (field == null) field = settingsManager.getToken()
return field return field
} }
@ -119,14 +114,10 @@ internal class DocumentsStorage(
field field
} }
fun isInitialized(): Boolean { /**
if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed * Resets this storage abstraction, forcing it to re-fetch cached values on next access.
val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false */
val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false fun reset(newToken: Long?) {
return kvEmpty && fullEmpty
}
fun reset(newToken: Long) {
storage = null storage = null
currentToken = newToken currentToken = newToken
rootBackupDir = null rootBackupDir = null
@ -138,26 +129,28 @@ internal class DocumentsStorage(
fun getAuthority(): String? = storage?.uri?.authority fun getAuthority(): String? = storage?.uri?.authority
@Throws(IOException::class) @Throws(IOException::class)
suspend fun getSetDir(token: Long = currentToken): DocumentFile? { suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentSetDir if (token == currentToken) return currentSetDir
return rootBackupDir?.findFileBlocking(context, token.toString()) return rootBackupDir?.findFileBlocking(context, token.toString())
} }
@Throws(IOException::class) @Throws(IOException::class)
suspend fun getKVBackupDir(token: Long = currentToken): DocumentFile? { suspend fun getKVBackupDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentKvBackupDir ?: throw IOException() if (token == currentToken) return currentKvBackupDir ?: throw IOException()
return getSetDir(token)?.findFileBlocking(context, DIRECTORY_KEY_VALUE_BACKUP) return getSetDir(token)?.findFileBlocking(context, DIRECTORY_KEY_VALUE_BACKUP)
} }
@Throws(IOException::class) @Throws(IOException::class)
suspend fun getOrCreateKVBackupDir(token: Long = currentToken): DocumentFile { suspend fun getOrCreateKVBackupDir(
token: Long = currentToken ?: error("no token")
): DocumentFile {
if (token == currentToken) return currentKvBackupDir ?: throw IOException() if (token == currentToken) return currentKvBackupDir ?: throw IOException()
val setDir = getSetDir(token) ?: throw IOException() val setDir = getSetDir(token) ?: throw IOException()
return setDir.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP) return setDir.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP)
} }
@Throws(IOException::class) @Throws(IOException::class)
suspend fun getFullBackupDir(token: Long = currentToken): DocumentFile? { suspend fun getFullBackupDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentFullBackupDir ?: throw IOException() if (token == currentToken) return currentFullBackupDir ?: throw IOException()
return getSetDir(token)?.findFileBlocking(context, DIRECTORY_FULL_BACKUP) return getSetDir(token)?.findFileBlocking(context, DIRECTORY_FULL_BACKUP)
} }
@ -195,12 +188,14 @@ internal suspend fun DocumentFile.createOrGetFile(
*/ */
@Throws(IOException::class) @Throws(IOException::class)
suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile { suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): DocumentFile {
return findFileBlocking(context, name) ?: createDirectory(name) ?: throw IOException() return findFileBlocking(context, name) ?: createDirectory(name)?.apply {
check(this.name == name) { "Directory named ${this.name}, but should be $name" }
} ?: throw IOException()
} }
@Throws(IOException::class) @Throws(IOException::class)
fun DocumentFile.deleteContents() { suspend fun DocumentFile.deleteContents(context: Context) {
for (file in listFiles()) file.delete() for (file in listFilesBlocking(context)) file.delete()
} }
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) { fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
@ -214,10 +209,10 @@ fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
* This prevents getting an empty list even though there are children to be listed. * This prevents getting an empty list even though there are children to be listed.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
suspend fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> { suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile> {
val resolver = context.contentResolver val resolver = context.contentResolver
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri)) val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
val projection = arrayOf(COLUMN_DOCUMENT_ID, COLUMN_MIME_TYPE) val projection = arrayOf(COLUMN_DOCUMENT_ID)
val result = ArrayList<DocumentFile>() val result = ArrayList<DocumentFile>()
try { try {
@ -229,20 +224,33 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): ArrayList<Document
}.use { cursor -> }.use { cursor ->
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val documentId = cursor.getString(0) val documentId = cursor.getString(0)
val isDirectory = cursor.getString(1) == MIME_TYPE_DIR val documentUri = buildDocumentUriUsingTree(uri, documentId)
val file = if (isDirectory) { result.add(getTreeDocumentFile(this, context, documentUri))
val treeUri = buildTreeDocumentUri(uri.authority, documentId)
DocumentFile.fromTreeUri(context, treeUri)!!
} else {
val documentUri = buildDocumentUriUsingTree(uri, documentId)
DocumentFile.fromSingleUri(context, documentUri)!!
}
result.add(file)
} }
} }
return result return result
} }
/**
* An extremely dirty reflection hack to instantiate a TreeDocumentFile with a parent.
*
* All other public ways to get a TreeDocumentFile only work from [Uri]s
* (e.g. [DocumentFile.fromTreeUri]) and always set parent to null.
*
* We have a test for this method to ensure CI will alert us when this reflection breaks.
* Also, [DocumentFile] is part of AndroidX, so we control the dependency and notice when it fails.
*/
@VisibleForTesting
internal fun getTreeDocumentFile(parent: DocumentFile, context: Context, uri: Uri): DocumentFile {
@SuppressWarnings("MagicNumber")
val constructor = parent.javaClass.declaredConstructors.find {
it.name == "androidx.documentfile.provider.TreeDocumentFile" && it.parameterCount == 3
}
check(constructor != null) { "Could not find constructor for TreeDocumentFile" }
constructor.isAccessible = true
return constructor.newInstance(parent, context, uri) as DocumentFile
}
/** /**
* Same as [DocumentFile.findFile] only that it re-queries when the first result was stale. * Same as [DocumentFile.findFile] only that it re-queries when the first result was stale.
* *

View file

@ -1,16 +1,15 @@
package com.stevesoltys.seedvault.settings package com.stevesoltys.seedvault.settings
import android.app.backup.RestoreSet
import android.content.Context import android.content.Context
import android.hardware.usb.UsbDevice import android.hardware.usb.UsbDevice
import android.net.Uri import android.net.Uri
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransport import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import java.util.concurrent.ConcurrentSkipListSet import java.util.concurrent.ConcurrentSkipListSet
import java.util.concurrent.atomic.AtomicBoolean
internal const val PREF_KEY_TOKEN = "token"
internal const val PREF_KEY_BACKUP_APK = "backup_apk" internal const val PREF_KEY_BACKUP_APK = "backup_apk"
private const val PREF_KEY_STORAGE_URI = "storageUri" private const val PREF_KEY_STORAGE_URI = "storageUri"
@ -28,7 +27,8 @@ class SettingsManager(context: Context) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
private var isStorageChanging: AtomicBoolean = AtomicBoolean(false) @Volatile
private var token: Long? = null
/** /**
* This gets accessed by non-UI threads when saving with [PreferenceManager] * This gets accessed by non-UI threads when saving with [PreferenceManager]
@ -39,6 +39,21 @@ class SettingsManager(context: Context) {
ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet())) ConcurrentSkipListSet(prefs.getStringSet(PREF_KEY_BACKUP_APP_BLACKLIST, emptySet()))
} }
fun getToken(): Long? = token ?: {
val value = prefs.getLong(PREF_KEY_TOKEN, 0L)
if (value == 0L) null else value
}()
/**
* Sets a new RestoreSet token.
* Should only be called by the [BackupCoordinator]
* to ensure that related work is performed after moving to a new token.
*/
fun setNewToken(newToken: Long) {
prefs.edit().putLong(PREF_KEY_TOKEN, newToken).apply()
token = newToken
}
// FIXME Storage is currently plugin specific and not generic // FIXME Storage is currently plugin specific and not generic
fun setStorage(storage: Storage) { fun setStorage(storage: Storage) {
prefs.edit() prefs.edit()
@ -57,25 +72,6 @@ class SettingsManager(context: Context) {
return Storage(uri, name, isUsb) return Storage(uri, name, isUsb)
} }
/**
* When [ConfigurableBackupTransport.initializeDevice] we try to avoid deleting all stored data,
* as this gets frequently called after network errors by SAF cloud providers.
*
* This method allows us to force a re-initialization of the underlying storage root
* when we change to a new storage provider.
* Currently, this causes us to create a new [RestoreSet].
*
* As part of the initialization, [getAndResetIsStorageChanging] should get called
* to prevent future calls from causing re-initializations.
*/
fun forceStorageInitialization() {
isStorageChanging.set(true)
}
fun getAndResetIsStorageChanging(): Boolean {
return isStorageChanging.getAndSet(false)
}
fun setFlashDrive(usb: FlashDrive?) { fun setFlashDrive(usb: FlashDrive?) {
if (usb == null) { if (usb == null) {
prefs.edit() prefs.edit()

View file

@ -4,13 +4,13 @@ import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import android.app.backup.RestoreSet
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.Clock import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
@ -20,6 +20,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.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS import java.util.concurrent.TimeUnit.DAYS
@ -52,6 +53,19 @@ internal class BackupCoordinator(
// Transport initialization and quota // Transport initialization and quota
// //
/**
* Starts a new [RestoreSet] with a new token (the current unix epoch in milliseconds).
* Call this at least once before calling [initializeDevice]
* which must be called after this method to properly initialize the backup transport.
*/
@Throws(IOException::class)
suspend fun startNewRestoreSet() {
val token = clock.time()
Log.i(TAG, "Starting new RestoreSet with token $token...")
settingsManager.setNewToken(token)
plugin.startNewRestoreSet(token)
}
/** /**
* Initialize the storage for this device, erasing all stored data. * Initialize the storage for this device, erasing all stored data.
* The transport may send the request immediately, or may buffer it. * The transport may send the request immediately, or may buffer it.
@ -70,28 +84,27 @@ internal class BackupCoordinator(
* @return One of [TRANSPORT_OK] (OK so far) or * @return One of [TRANSPORT_OK] (OK so far) or
* [TRANSPORT_ERROR] (to retry following network error or other failure). * [TRANSPORT_ERROR] (to retry following network error or other failure).
*/ */
suspend fun initializeDevice(): Int { suspend fun initializeDevice(): Int = try {
Log.i(TAG, "Initialize Device!") val token = settingsManager.getToken()
return try { if (token == null) {
val token = clock.time() Log.i(TAG, "No RestoreSet started, initialization is no-op.")
if (plugin.initializeDevice(token)) { } else {
Log.d(TAG, "Resetting backup metadata...") Log.i(TAG, "Initialize Device!")
plugin.getMetadataOutputStream().use { plugin.initializeDevice()
metadataManager.onDeviceInitialization(token, it) Log.d(TAG, "Resetting backup metadata for token $token...")
} plugin.getMetadataOutputStream().use {
} else { metadataManager.onDeviceInitialization(token, it)
Log.d(TAG, "Storage was already initialized, doing no-op")
} }
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error initializing device", e)
// Show error notification if we were ready for backups
if (getBackupBackoff() == 0L) nm.onBackupError()
TRANSPORT_ERROR
} }
// [finishBackup] will only be called when we return [TRANSPORT_OK] here
// so we remember that we initialized successfully
calledInitialize = true
TRANSPORT_OK
} catch (e: IOException) {
Log.e(TAG, "Error initializing device", e)
// Show error notification if we were ready for backups
if (getBackupBackoff() == 0L) nm.onBackupError()
TRANSPORT_ERROR
} }
fun isAppEligibleForBackup( fun isAppEligibleForBackup(

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.transport.backup package com.stevesoltys.seedvault.transport.backup
import android.app.backup.RestoreSet
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
@ -11,13 +12,18 @@ interface BackupPlugin {
val fullBackupPlugin: FullBackupPlugin val fullBackupPlugin: FullBackupPlugin
/** /**
* Initialize the storage for this device, erasing all stored data. * Start a new [RestoreSet] with the given token.
* *
* @return true if the device needs initialization or * This is typically followed by a call to [initializeDevice].
* false if the device was initialized already and initialization should be a no-op.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
suspend fun initializeDevice(newToken: Long): Boolean suspend fun startNewRestoreSet(token: Long)
/**
* Initialize the storage for this device, erasing all stored data in the current [RestoreSet].
*/
@Throws(IOException::class)
suspend fun initializeDevice()
/** /**
* Returns an [OutputStream] for writing backup metadata. * Returns an [OutputStream] for writing backup metadata.

View file

@ -112,7 +112,7 @@ internal class KVRestore(
* Return a list of the records (represented by key files) in the given directory, * Return a list of the records (represented by key files) in the given directory,
* sorted lexically by the Base64-decoded key file name, not by the on-disk filename. * sorted lexically by the Base64-decoded key file name, not by the on-disk filename.
*/ */
private fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? { private suspend fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
val records: List<String> = try { val records: List<String> = try {
plugin.listRecords(token, packageInfo) plugin.listRecords(token, packageInfo)
} catch (e: IOException) { } catch (e: IOException) {

View file

@ -20,7 +20,7 @@ interface KVRestorePlugin {
* For file-based plugins, this is usually a list of file names in the package directory. * For file-based plugins, this is usually a list of file names in the package directory.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
fun listRecords(token: Long, packageInfo: PackageInfo): List<String> suspend fun listRecords(token: Long, packageInfo: PackageInfo): List<String>
/** /**
* Return an [InputStream] for the given token, package and key * Return an [InputStream] for the given token, package and key

View file

@ -13,7 +13,6 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import androidx.collection.LongSparseArray import androidx.collection.LongSparseArray
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.UnsupportedVersionException
@ -22,18 +21,19 @@ import com.stevesoltys.seedvault.metadata.DecryptionFailedException
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.MetadataReader import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import libcore.io.IoUtils.closeQuietly import libcore.io.IoUtils.closeQuietly
import java.io.IOException import java.io.IOException
private class RestoreCoordinatorState( private class RestoreCoordinatorState(
internal val token: Long, val token: Long,
internal val packages: Iterator<PackageInfo>, val packages: Iterator<PackageInfo>,
/** /**
* Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@ * Optional [PackageInfo] for single package restore, to reduce data needed to read for @pm@
*/ */
internal val pmPackageInfo: PackageInfo? val pmPackageInfo: PackageInfo?
) { ) {
internal var currentPackage: String? = null var currentPackage: String? = null
} }
private val TAG = RestoreCoordinator::class.java.simpleName private val TAG = RestoreCoordinator::class.java.simpleName
@ -106,8 +106,9 @@ internal class RestoreCoordinator(
* or 0 if there is no backup set available corresponding to the current device state. * or 0 if there is no backup set available corresponding to the current device state.
*/ */
fun getCurrentRestoreSet(): Long { fun getCurrentRestoreSet(): Long {
return metadataManager.getBackupToken() return (settingsManager.getToken() ?: 0L).apply {
.apply { Log.i(TAG, "Got current restore set token: $this") } Log.i(TAG, "Got current restore set token: $this")
}
} }
/** /**

View file

@ -8,36 +8,51 @@ import android.net.Uri
import android.os.UserHandle import android.os.UserHandle
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.viewModelScope
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.transport.TRANSPORT_ID import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.requestBackup import com.stevesoltys.seedvault.transport.requestBackup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.IOException
private val TAG = BackupStorageViewModel::class.java.simpleName 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,
settingsManager: SettingsManager) : StorageViewModel(app, settingsManager) { private val backupCoordinator: BackupCoordinator,
settingsManager: SettingsManager
) : StorageViewModel(app, settingsManager) {
override val isRestoreOperation = false override val isRestoreOperation = false
override fun onLocationSet(uri: Uri) { override fun onLocationSet(uri: Uri) {
val isUsb = saveStorage(uri) val isUsb = saveStorage(uri)
settingsManager.forceStorageInitialization() viewModelScope.launch(Dispatchers.IO) {
try {
// will also generate a new backup token for the new restore set
backupCoordinator.startNewRestoreSet()
// initialize the new location, will also generate a new backup token // initialize the new location
val observer = InitializationObserver() backupManager.initializeTransportsForUser(
backupManager.initializeTransportsForUser(UserHandle.myUserId(), arrayOf(TRANSPORT_ID), observer) UserHandle.myUserId(),
arrayOf(TRANSPORT_ID),
// if storage is on USB and this is not SetupWizard, do a backup right away // if storage is on USB and this is not SetupWizard, do a backup right away
if (isUsb && !isSetupWizard) Thread { InitializationObserver(isUsb && !isSetupWizard)
requestBackup(app) )
}.start() } catch (e: IOException) {
Log.e(TAG, "Error starting new RestoreSet", e)
onInitializationError()
}
}
} }
@WorkerThread @WorkerThread
private inner class InitializationObserver : IBackupObserver.Stub() { private inner class InitializationObserver(val requestBackup: Boolean) :
IBackupObserver.Stub() {
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) { override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
// noop // noop
} }
@ -53,12 +68,19 @@ internal class BackupStorageViewModel(
if (status == 0) { if (status == 0) {
// notify the UI that the location has been set // notify the UI that the location has been set
mLocationChecked.postEvent(LocationResult()) mLocationChecked.postEvent(LocationResult())
if (requestBackup) {
requestBackup(app)
}
} else { } else {
// notify the UI that the location was invalid // notify the UI that the location was invalid
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error) onInitializationError()
mLocationChecked.postEvent(LocationResult(errorMsg))
} }
} }
} }
private fun onInitializationError() {
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
mLocationChecked.postEvent(LocationResult(errorMsg))
}
} }

View file

@ -0,0 +1,65 @@
package com.stevesoltys.seedvault.plugins.saf
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.backup.BackupTest
import com.stevesoltys.seedvault.transport.backup.FullBackupPlugin
import com.stevesoltys.seedvault.transport.backup.KVBackupPlugin
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
@Suppress("BlockingMethodInNonBlockingContext")
internal class BackupPluginTest : BackupTest() {
private val storage = mockk<DocumentsStorage>()
private val kvBackupPlugin: KVBackupPlugin = mockk<DocumentsProviderKVBackup>()
private val fullBackupPlugin: FullBackupPlugin = mockk<DocumentsProviderFullBackup>()
private val plugin = DocumentsProviderBackupPlugin(
context,
storage,
kvBackupPlugin,
fullBackupPlugin
)
private val setDir: DocumentFile = mockk()
private val kvDir: DocumentFile = mockk()
private val fullDir: DocumentFile = mockk()
init {
// to mock extension functions on DocumentFile
mockkStatic("com.stevesoltys.seedvault.plugins.saf.DocumentsStorageKt")
}
@Test
fun `test startNewRestoreSet`() = runBlocking {
every { storage.reset(token) } just Runs
every { storage getProperty "rootBackupDir" } returns setDir
plugin.startNewRestoreSet(token)
}
@Test
fun `test initializeDevice`() = runBlocking {
// get current set dir and for that the current token
every { storage getProperty "currentToken" } returns token
every { settingsManager.getToken() } returns token
coEvery { storage.getSetDir(token) } returns setDir
// delete contents of current set dir
coEvery { setDir.listFilesBlocking(context) } returns listOf(kvDir)
every { kvDir.delete() } returns true
// reset storage
every { storage.reset(null) } just Runs
// create kv and full dir
every { storage getProperty "currentKvBackupDir" } returns kvDir
every { storage getProperty "currentFullBackupDir" } returns fullDir
plugin.initializeDevice()
}
}

View file

@ -0,0 +1,43 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.mockk
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.stopKoin
@RunWith(AndroidJUnit4::class)
internal class DocumentFileTest {
private val context: Context = mockk()
private val parentUri: Uri = Uri.parse(
"content://com.android.externalstorage.documents/tree/" +
"primary%3A/document/primary%3A.SeedVaultAndroidBackup"
)
private val parentFile: DocumentFile = DocumentFile.fromTreeUri(context, parentUri)!!
private val uri: Uri = Uri.parse(
"content://com.android.externalstorage.documents/tree/" +
"primary%3A/document/primary%3A.SeedVaultAndroidBackup%2Ftest"
)
@After
fun afterEachTest() {
stopKoin()
}
@Test
fun `test ugly getTreeDocumentFile reflection hack`() {
assertTrue(DocumentsContract.isTreeUri(uri))
val file = getTreeDocumentFile(parentFile, context, uri)
assertEquals(uri, file.uri)
assertEquals(parentFile, file.parentFile)
}
}

View file

@ -7,7 +7,6 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.RestoreDescription import android.app.backup.RestoreDescription
import android.app.backup.RestoreDescription.TYPE_FULL_STREAM import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
import com.stevesoltys.seedvault.crypto.CryptoImpl import com.stevesoltys.seedvault.crypto.CryptoImpl
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
@ -35,6 +34,7 @@ import com.stevesoltys.seedvault.transport.restore.KVRestorePlugin
import com.stevesoltys.seedvault.transport.restore.OutputFactory import com.stevesoltys.seedvault.transport.restore.OutputFactory
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.CapturingSlot import io.mockk.CapturingSlot
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
@ -186,7 +186,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
val backupDataOutput = mockk<BackupDataOutput>() val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
val rInputStream2 = ByteArrayInputStream(bOutputStream2.toByteArray()) val rInputStream2 = ByteArrayInputStream(bOutputStream2.toByteArray())
every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264) coEvery { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64, key264)
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
coEvery { coEvery {
kvRestorePlugin.getInputStreamForRecord( kvRestorePlugin.getInputStreamForRecord(
@ -255,7 +255,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
// restore finds the backed up key and writes the decrypted value // restore finds the backed up key and writes the decrypted value
val backupDataOutput = mockk<BackupDataOutput>() val backupDataOutput = mockk<BackupDataOutput>()
val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray()) val rInputStream = ByteArrayInputStream(bOutputStream.toByteArray())
every { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64) coEvery { kvRestorePlugin.listRecords(token, packageInfo) } returns listOf(key64)
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput every { outputFactory.getBackupDataOutput(fileDescriptor) } returns backupDataOutput
coEvery { coEvery {
kvRestorePlugin.getInputStreamForRecord( kvRestorePlugin.getInputStreamForRecord(

View file

@ -8,7 +8,6 @@ import android.content.pm.PackageInfo
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.coAssertThrows import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
@ -18,6 +17,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.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
@ -63,9 +63,18 @@ internal class BackupCoordinatorTest : BackupTest() {
private val storage = Storage(Uri.EMPTY, getRandomString(), false) private val storage = Storage(Uri.EMPTY, getRandomString(), false)
@Test @Test
fun `device initialization succeeds and delegates to plugin`() = runBlocking { fun `starting a new restore set works as expected`() = runBlocking {
every { clock.time() } returns token every { clock.time() } returns token
coEvery { plugin.initializeDevice(token) } returns true // TODO test when false every { settingsManager.setNewToken(token) } just Runs
coEvery { plugin.startNewRestoreSet(token) } just Runs
backup.startNewRestoreSet()
}
@Test
fun `device initialization succeeds and delegates to plugin`() = runBlocking {
every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice() } just Runs
coEvery { plugin.getMetadataOutputStream() } returns metadataOutputStream coEvery { plugin.getMetadataOutputStream() } 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
@ -79,9 +88,8 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
@Test @Test
fun `device initialization does no-op when already initialized`() = runBlocking { fun `device initialization does no-op when no token available`() = runBlocking {
every { clock.time() } returns token every { settingsManager.getToken() } returns null
coEvery { plugin.initializeDevice(token) } returns false
every { kv.hasState() } returns false every { kv.hasState() } returns false
every { full.hasState() } returns false every { full.hasState() } returns false
@ -91,8 +99,8 @@ internal class BackupCoordinatorTest : BackupTest() {
@Test @Test
fun `error notification when device initialization fails`() = runBlocking { fun `error notification when device initialization fails`() = runBlocking {
every { clock.time() } returns token every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice(token) } throws IOException() coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage every { settingsManager.getStorage() } returns storage
every { notificationManager.onBackupError() } just Runs every { notificationManager.onBackupError() } just Runs
@ -112,8 +120,8 @@ internal class BackupCoordinatorTest : BackupTest() {
val storage = mockk<Storage>() val storage = mockk<Storage>()
val documentFile = mockk<DocumentFile>() val documentFile = mockk<DocumentFile>()
every { clock.time() } returns token every { settingsManager.getToken() } returns token
coEvery { plugin.initializeDevice(token) } throws IOException() coEvery { plugin.initializeDevice() } throws IOException()
every { settingsManager.getStorage() } returns storage every { settingsManager.getStorage() } returns storage
every { storage.isUsb } returns true every { storage.isUsb } returns true
every { storage.getDocumentFile(context) } returns documentFile every { storage.getDocumentFile(context) } returns documentFile

View file

@ -56,7 +56,7 @@ internal class KVRestoreTest : RestoreTest() {
fun `listing records throws`() = runBlocking { fun `listing records throws`() = runBlocking {
restore.initializeState(token, packageInfo) restore.initializeState(token, packageInfo)
every { plugin.listRecords(token, packageInfo) } throws IOException() coEvery { plugin.listRecords(token, packageInfo) } throws IOException()
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor)) assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
} }
@ -208,7 +208,7 @@ internal class KVRestoreTest : RestoreTest() {
} }
private fun getRecordsAndOutput(recordKeys: List<String> = listOf(key64)) { private fun getRecordsAndOutput(recordKeys: List<String> = listOf(key64)) {
every { plugin.listRecords(token, packageInfo) } returns recordKeys coEvery { plugin.listRecords(token, packageInfo) } returns recordKeys
every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output every { outputFactory.getBackupDataOutput(fileDescriptor) } returns output
} }

View file

@ -9,7 +9,6 @@ import android.app.backup.RestoreDescription.TYPE_KEY_VALUE
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.coAssertThrows import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupMetadata import com.stevesoltys.seedvault.metadata.BackupMetadata
@ -18,6 +17,7 @@ import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
@ -91,7 +91,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test @Test
fun `getCurrentRestoreSet() delegates to plugin`() { fun `getCurrentRestoreSet() delegates to plugin`() {
every { metadataManager.getBackupToken() } returns token every { settingsManager.getToken() } returns token
assertEquals(token, restore.getCurrentRestoreSet()) assertEquals(token, restore.getCurrentRestoreSet())
} }