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:
parent
80187c8c70
commit
1b9a4feddd
26 changed files with 454 additions and 195 deletions
48
.idea/runConfigurations/Instrumentation_Tests.xml
Normal file
48
.idea/runConfigurations/Instrumentation_Tests.xml
Normal 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>
|
|
@ -1,8 +1,8 @@
|
|||
package com.stevesoltys.seedvault
|
||||
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import androidx.test.runner.AndroidJUnit4
|
||||
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
|
||||
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
|
||||
import org.junit.Assert.assertTrue
|
||||
|
|
|
@ -3,8 +3,11 @@ package com.stevesoltys.seedvault
|
|||
import androidx.test.core.content.pm.PackageInfoBuilder
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
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.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.DocumentsStorage
|
||||
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.settings.SettingsManager
|
||||
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 io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
@ -35,27 +42,41 @@ import kotlin.random.Random
|
|||
class PluginTest : KoinComponent {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val metadataManager: MetadataManager by inject()
|
||||
private val settingsManager: SettingsManager by inject()
|
||||
private val mockedSettingsManager: SettingsManager = mockk()
|
||||
private val storage = DocumentsStorage(context, metadataManager, mockedSettingsManager)
|
||||
private val backupPlugin: BackupPlugin = DocumentsProviderBackupPlugin(context, storage)
|
||||
private val restorePlugin: RestorePlugin = DocumentsProviderRestorePlugin(context, storage)
|
||||
private val storage = DocumentsStorage(context, mockedSettingsManager)
|
||||
|
||||
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 packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
|
||||
private val packageInfo2 = PackageInfoBuilder.newBuilder().setPackageName("net.example").build()
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fun setup() = runBlocking {
|
||||
every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage()
|
||||
storage.rootBackupDir?.deleteContents()
|
||||
storage.rootBackupDir?.deleteContents(context)
|
||||
?: error("Select a storage location in the app first!")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
storage.rootBackupDir?.deleteContents()
|
||||
fun tearDown() = runBlocking {
|
||||
storage.rootBackupDir?.deleteContents(context)
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -77,13 +98,12 @@ class PluginTest : KoinComponent {
|
|||
val uri = settingsManager.getStorage()?.getDocumentFile(context)?.uri ?: error("no storage")
|
||||
assertFalse(restorePlugin.hasBackup(uri))
|
||||
|
||||
// define storage changing state for later
|
||||
every {
|
||||
mockedSettingsManager.getAndResetIsStorageChanging()
|
||||
} returns true andThen true andThen false
|
||||
// prepare returned tokens requested when initializing device
|
||||
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
|
||||
|
||||
// device needs initialization, because new and storage is changing
|
||||
assertTrue(backupPlugin.initializeDevice(newToken = token))
|
||||
// start new restore set and initialize device afterwards
|
||||
backupPlugin.startNewRestoreSet(token)
|
||||
backupPlugin.initializeDevice()
|
||||
|
||||
// write metadata (needed for backup to be recognized)
|
||||
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
|
||||
|
@ -92,22 +112,29 @@ class PluginTest : KoinComponent {
|
|||
assertEquals(1, restorePlugin.getAvailableBackups()?.toList()?.size)
|
||||
assertTrue(restorePlugin.hasBackup(uri))
|
||||
|
||||
// initializing again (while changing storage) does add a restore set
|
||||
assertTrue(backupPlugin.initializeDevice(newToken = token + 1))
|
||||
// initializing again (with another restore set) does add a restore set
|
||||
backupPlugin.startNewRestoreSet(token + 1)
|
||||
backupPlugin.initializeDevice()
|
||||
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
|
||||
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
|
||||
assertTrue(restorePlugin.hasBackup(uri))
|
||||
|
||||
// initializing again (without changing storage) doesn't change number of restore sets
|
||||
assertFalse(backupPlugin.initializeDevice(newToken = token + 2))
|
||||
// initializing again (without new restore set) doesn't change number of restore sets
|
||||
backupPlugin.initializeDevice()
|
||||
backupPlugin.getMetadataOutputStream().writeAndClose(getRandomByteArray())
|
||||
assertEquals(2, restorePlugin.getAvailableBackups()?.toList()?.size)
|
||||
|
||||
// ensure that the new backup dirs exist
|
||||
assertTrue(storage.currentKvBackupDir!!.exists())
|
||||
assertTrue(storage.currentFullBackupDir!!.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMetadataWriteRead() = runBlocking(Dispatchers.IO) {
|
||||
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true andThen false
|
||||
assertTrue(backupPlugin.initializeDevice(newToken = token))
|
||||
every { mockedSettingsManager.getToken() } returns token
|
||||
|
||||
backupPlugin.startNewRestoreSet(token)
|
||||
backupPlugin.initializeDevice()
|
||||
|
||||
// write metadata
|
||||
val metadata = getRandomByteArray()
|
||||
|
@ -124,7 +151,8 @@ class PluginTest : KoinComponent {
|
|||
assertReadEquals(metadata, availableBackups[0].inputStream)
|
||||
|
||||
// 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()
|
||||
check(availableBackups != null)
|
||||
assertEquals(1, availableBackups.size)
|
||||
|
@ -311,8 +339,8 @@ class PluginTest : KoinComponent {
|
|||
}
|
||||
|
||||
private fun initStorage(token: Long) = runBlocking {
|
||||
every { mockedSettingsManager.getAndResetIsStorageChanging() } returns true
|
||||
assertTrue(backupPlugin.initializeDevice(newToken = token))
|
||||
every { mockedSettingsManager.getToken() } returns token
|
||||
backupPlugin.initializeDevice()
|
||||
}
|
||||
|
||||
private fun isNextcloud(): Boolean {
|
||||
|
|
|
@ -45,7 +45,7 @@ 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, metadataManager, settingsManager)
|
||||
private val storage = DocumentsStorage(context, settingsManager)
|
||||
|
||||
private val filename = getRandomBase64()
|
||||
private lateinit var file: DocumentFile
|
||||
|
@ -96,6 +96,7 @@ class DocumentsStorageTest : KoinComponent {
|
|||
val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!)
|
||||
assertNotNull(foundFile)
|
||||
assertEquals(filename, foundFile!!.name)
|
||||
assertEquals(storage.rootBackupDir!!.uri, foundFile.parentFile?.uri)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -8,6 +8,7 @@ import android.os.Build
|
|||
import android.os.ServiceManager.getService
|
||||
import com.stevesoltys.seedvault.crypto.cryptoModule
|
||||
import com.stevesoltys.seedvault.header.headerModule
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.metadataModule
|
||||
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule
|
||||
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.storage.BackupStorageViewModel
|
||||
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.androidLogger
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
|
@ -39,7 +41,7 @@ class App : Application() {
|
|||
|
||||
viewModel { SettingsViewModel(this@App, get(), get(), get(), 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 { RestoreViewModel(this@App, get(), get(), get(), get(), get()) }
|
||||
}
|
||||
|
@ -49,7 +51,8 @@ class App : Application() {
|
|||
startKoin {
|
||||
androidLogger()
|
||||
androidContext(this@App)
|
||||
modules(listOf(
|
||||
modules(
|
||||
listOf(
|
||||
cryptoModule,
|
||||
headerModule,
|
||||
metadataModule,
|
||||
|
@ -57,7 +60,25 @@ class App : Application() {
|
|||
backupModule,
|
||||
restoreModule,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -178,6 +178,7 @@ class MetadataManager(
|
|||
* If the token is 0L, it is not yet initialized and must not be used for anything.
|
||||
*/
|
||||
@Synchronized
|
||||
@Deprecated("Responsibility for current token moved to SettingsManager", ReplaceWith("settingsManager.getToken()"))
|
||||
fun getBackupToken(): Long = metadata.token
|
||||
|
||||
/**
|
||||
|
|
|
@ -14,41 +14,34 @@ private const val MIME_TYPE_APK = "application/vnd.android.package-archive"
|
|||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class DocumentsProviderBackupPlugin(
|
||||
private val context: Context,
|
||||
private val storage: DocumentsStorage
|
||||
private val storage: DocumentsStorage,
|
||||
override val kvBackupPlugin: KVBackupPlugin,
|
||||
override val fullBackupPlugin: FullBackupPlugin
|
||||
) : BackupPlugin {
|
||||
|
||||
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)
|
||||
override suspend fun initializeDevice(newToken: Long): Boolean {
|
||||
// check if storage is already initialized
|
||||
if (storage.isInitialized()) return false
|
||||
|
||||
// TODO consider not creating new RestoreSets, but continue working within the existing one.
|
||||
override suspend fun startNewRestoreSet(token: Long) {
|
||||
// reset current storage
|
||||
storage.reset(newToken)
|
||||
storage.reset(token)
|
||||
|
||||
// get or create root backup dir
|
||||
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
|
||||
val kvDir = storage.currentKvBackupDir
|
||||
val fullDir = storage.currentFullBackupDir
|
||||
|
||||
// wipe existing data
|
||||
storage.getSetDir()?.findFileBlocking(context, FILE_BACKUP_METADATA)?.delete()
|
||||
kvDir?.deleteContents()
|
||||
fullDir?.deleteContents()
|
||||
|
||||
return true
|
||||
storage.currentKvBackupDir ?: throw IOException()
|
||||
storage.currentFullBackupDir ?: throw IOException()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
|
|
|
@ -12,8 +12,8 @@ private val TAG = DocumentsProviderFullBackup::class.java.simpleName
|
|||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class DocumentsProviderFullBackup(
|
||||
private val storage: DocumentsStorage,
|
||||
private val context: Context
|
||||
private val context: Context,
|
||||
private val storage: DocumentsStorage
|
||||
) : FullBackupPlugin {
|
||||
|
||||
override fun getQuota() = DEFAULT_QUOTA_FULL_BACKUP
|
||||
|
|
|
@ -14,8 +14,8 @@ const val MAX_KEY_LENGTH_NEXTCLOUD = 225
|
|||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class DocumentsProviderKVBackup(
|
||||
private val storage: DocumentsStorage,
|
||||
private val context: Context
|
||||
private val context: Context,
|
||||
private val storage: DocumentsStorage
|
||||
) : KVBackupPlugin {
|
||||
|
||||
private var packageFile: DocumentFile? = null
|
||||
|
@ -27,7 +27,7 @@ internal class DocumentsProviderKVBackup(
|
|||
val packageFile =
|
||||
storage.currentKvBackupDir?.findFileBlocking(context, packageInfo.packageName)
|
||||
?: return false
|
||||
return packageFile.listFiles().isNotEmpty()
|
||||
return packageFile.listFilesBlocking(context).isNotEmpty()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
|
|
|
@ -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()
|
||||
packageDir.assertRightFile(packageInfo)
|
||||
return packageDir.listFiles()
|
||||
return packageDir.listFilesBlocking(context)
|
||||
.filter { file -> file.name != null }
|
||||
.map { file -> file.name!! }
|
||||
}
|
||||
|
|
|
@ -1,12 +1,22 @@
|
|||
package com.stevesoltys.seedvault.plugins.saf
|
||||
|
||||
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 org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val documentsProviderModule = module {
|
||||
single { DocumentsStorage(androidContext(), get(), get()) }
|
||||
single<BackupPlugin> { DocumentsProviderBackupPlugin(androidContext(), get()) }
|
||||
single<RestorePlugin> { DocumentsProviderRestorePlugin(androidContext(), get()) }
|
||||
single { DocumentsStorage(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()) }
|
||||
}
|
||||
|
|
|
@ -19,17 +19,11 @@ private val TAG = DocumentsProviderRestorePlugin::class.java.simpleName
|
|||
@Suppress("BlockingMethodInNonBlockingContext") // all methods do I/O
|
||||
internal class DocumentsProviderRestorePlugin(
|
||||
private val context: Context,
|
||||
private val storage: DocumentsStorage
|
||||
private val storage: DocumentsStorage,
|
||||
override val kvRestorePlugin: KVRestorePlugin,
|
||||
override val fullRestorePlugin: FullRestorePlugin
|
||||
) : RestorePlugin {
|
||||
|
||||
override val kvRestorePlugin: KVRestorePlugin by lazy {
|
||||
DocumentsProviderKVRestorePlugin(context, storage)
|
||||
}
|
||||
|
||||
override val fullRestorePlugin: FullRestorePlugin by lazy {
|
||||
DocumentsProviderFullRestorePlugin(context, storage)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override suspend fun hasBackup(uri: Uri): Boolean {
|
||||
val parent = DocumentFile.fromTreeUri(context, uri) ?: throw AssertionError()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@file:Suppress("EXPERIMENTAL_API_USAGE", "BlockingMethodInNonBlockingContext")
|
||||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
||||
package com.stevesoltys.seedvault.plugins.saf
|
||||
|
||||
|
@ -9,17 +9,13 @@ import android.database.Cursor
|
|||
import android.net.Uri
|
||||
import android.os.FileUtils.closeQuietly
|
||||
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.buildChildDocumentsUriUsingTree
|
||||
import android.provider.DocumentsContract.buildDocumentUriUsingTree
|
||||
import android.provider.DocumentsContract.buildTreeDocumentUri
|
||||
import android.provider.DocumentsContract.getDocumentId
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.settings.Storage
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
|
@ -42,7 +38,6 @@ private val TAG = DocumentsStorage::class.java.simpleName
|
|||
|
||||
internal class DocumentsStorage(
|
||||
private val context: Context,
|
||||
private val metadataManager: MetadataManager,
|
||||
private val settingsManager: SettingsManager
|
||||
) {
|
||||
|
||||
|
@ -73,9 +68,9 @@ internal class DocumentsStorage(
|
|||
field
|
||||
}
|
||||
|
||||
private var currentToken: Long = 0L
|
||||
private var currentToken: Long? = null
|
||||
get() {
|
||||
if (field == 0L) field = metadataManager.getBackupToken()
|
||||
if (field == null) field = settingsManager.getToken()
|
||||
return field
|
||||
}
|
||||
|
||||
|
@ -119,14 +114,10 @@ internal class DocumentsStorage(
|
|||
field
|
||||
}
|
||||
|
||||
fun isInitialized(): Boolean {
|
||||
if (settingsManager.getAndResetIsStorageChanging()) return false // storage location has changed
|
||||
val kvEmpty = currentKvBackupDir?.listFiles()?.isEmpty() ?: false
|
||||
val fullEmpty = currentFullBackupDir?.listFiles()?.isEmpty() ?: false
|
||||
return kvEmpty && fullEmpty
|
||||
}
|
||||
|
||||
fun reset(newToken: Long) {
|
||||
/**
|
||||
* Resets this storage abstraction, forcing it to re-fetch cached values on next access.
|
||||
*/
|
||||
fun reset(newToken: Long?) {
|
||||
storage = null
|
||||
currentToken = newToken
|
||||
rootBackupDir = null
|
||||
|
@ -138,26 +129,28 @@ internal class DocumentsStorage(
|
|||
fun getAuthority(): String? = storage?.uri?.authority
|
||||
|
||||
@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
|
||||
return rootBackupDir?.findFileBlocking(context, token.toString())
|
||||
}
|
||||
|
||||
@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()
|
||||
return getSetDir(token)?.findFileBlocking(context, DIRECTORY_KEY_VALUE_BACKUP)
|
||||
}
|
||||
|
||||
@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()
|
||||
val setDir = getSetDir(token) ?: throw IOException()
|
||||
return setDir.createOrGetDirectory(context, DIRECTORY_KEY_VALUE_BACKUP)
|
||||
}
|
||||
|
||||
@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()
|
||||
return getSetDir(token)?.findFileBlocking(context, DIRECTORY_FULL_BACKUP)
|
||||
}
|
||||
|
@ -195,12 +188,14 @@ internal suspend fun DocumentFile.createOrGetFile(
|
|||
*/
|
||||
@Throws(IOException::class)
|
||||
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)
|
||||
fun DocumentFile.deleteContents() {
|
||||
for (file in listFiles()) file.delete()
|
||||
suspend fun DocumentFile.deleteContents(context: Context) {
|
||||
for (file in listFilesBlocking(context)) file.delete()
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
suspend fun DocumentFile.listFilesBlocking(context: Context): ArrayList<DocumentFile> {
|
||||
suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile> {
|
||||
val resolver = context.contentResolver
|
||||
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>()
|
||||
|
||||
try {
|
||||
|
@ -229,20 +224,33 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): ArrayList<Document
|
|||
}.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val documentId = cursor.getString(0)
|
||||
val isDirectory = cursor.getString(1) == MIME_TYPE_DIR
|
||||
val file = if (isDirectory) {
|
||||
val treeUri = buildTreeDocumentUri(uri.authority, documentId)
|
||||
DocumentFile.fromTreeUri(context, treeUri)!!
|
||||
} else {
|
||||
val documentUri = buildDocumentUriUsingTree(uri, documentId)
|
||||
DocumentFile.fromSingleUri(context, documentUri)!!
|
||||
}
|
||||
result.add(file)
|
||||
val documentUri = buildDocumentUriUsingTree(uri, documentId)
|
||||
result.add(getTreeDocumentFile(this, context, documentUri))
|
||||
}
|
||||
}
|
||||
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.
|
||||
*
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
package com.stevesoltys.seedvault.settings
|
||||
|
||||
import android.app.backup.RestoreSet
|
||||
import android.content.Context
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.net.Uri
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
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.atomic.AtomicBoolean
|
||||
|
||||
internal const val PREF_KEY_TOKEN = "token"
|
||||
internal const val PREF_KEY_BACKUP_APK = "backup_apk"
|
||||
|
||||
private const val PREF_KEY_STORAGE_URI = "storageUri"
|
||||
|
@ -28,7 +27,8 @@ class SettingsManager(context: 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]
|
||||
|
@ -39,6 +39,21 @@ class SettingsManager(context: Context) {
|
|||
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
|
||||
fun setStorage(storage: Storage) {
|
||||
prefs.edit()
|
||||
|
@ -57,25 +72,6 @@ class SettingsManager(context: Context) {
|
|||
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?) {
|
||||
if (usb == null) {
|
||||
prefs.edit()
|
||||
|
|
|
@ -4,13 +4,13 @@ import android.app.backup.BackupTransport.TRANSPORT_ERROR
|
|||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
||||
import android.app.backup.BackupTransport.TRANSPORT_PACKAGE_REJECTED
|
||||
import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
||||
import android.app.backup.RestoreSet
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.Clock
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
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.UNKNOWN_ERROR
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit.DAYS
|
||||
|
||||
|
@ -52,6 +53,19 @@ internal class BackupCoordinator(
|
|||
// 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.
|
||||
* 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
|
||||
* [TRANSPORT_ERROR] (to retry following network error or other failure).
|
||||
*/
|
||||
suspend fun initializeDevice(): Int {
|
||||
Log.i(TAG, "Initialize Device!")
|
||||
return try {
|
||||
val token = clock.time()
|
||||
if (plugin.initializeDevice(token)) {
|
||||
Log.d(TAG, "Resetting backup metadata...")
|
||||
plugin.getMetadataOutputStream().use {
|
||||
metadataManager.onDeviceInitialization(token, it)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Storage was already initialized, doing no-op")
|
||||
suspend fun initializeDevice(): Int = try {
|
||||
val token = settingsManager.getToken()
|
||||
if (token == null) {
|
||||
Log.i(TAG, "No RestoreSet started, initialization is no-op.")
|
||||
} else {
|
||||
Log.i(TAG, "Initialize Device!")
|
||||
plugin.initializeDevice()
|
||||
Log.d(TAG, "Resetting backup metadata for token $token...")
|
||||
plugin.getMetadataOutputStream().use {
|
||||
metadataManager.onDeviceInitialization(token, it)
|
||||
}
|
||||
// [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(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.stevesoltys.seedvault.transport.backup
|
||||
|
||||
import android.app.backup.RestoreSet
|
||||
import android.content.pm.PackageInfo
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
@ -11,13 +12,18 @@ interface BackupPlugin {
|
|||
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
|
||||
* false if the device was initialized already and initialization should be a no-op.
|
||||
* This is typically followed by a call to [initializeDevice].
|
||||
*/
|
||||
@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.
|
||||
|
|
|
@ -112,7 +112,7 @@ internal class KVRestore(
|
|||
* 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.
|
||||
*/
|
||||
private fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
|
||||
private suspend fun getSortedKeys(token: Long, packageInfo: PackageInfo): List<DecodedKey>? {
|
||||
val records: List<String> = try {
|
||||
plugin.listRecords(token, packageInfo)
|
||||
} catch (e: IOException) {
|
||||
|
|
|
@ -20,7 +20,7 @@ interface KVRestorePlugin {
|
|||
* For file-based plugins, this is usually a list of file names in the package directory.
|
||||
*/
|
||||
@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
|
||||
|
|
|
@ -13,7 +13,6 @@ import android.content.pm.PackageInfo
|
|||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.collection.LongSparseArray
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.R
|
||||
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.MetadataReader
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import libcore.io.IoUtils.closeQuietly
|
||||
import java.io.IOException
|
||||
|
||||
private class RestoreCoordinatorState(
|
||||
internal val token: Long,
|
||||
internal val packages: Iterator<PackageInfo>,
|
||||
val token: Long,
|
||||
val packages: Iterator<PackageInfo>,
|
||||
/**
|
||||
* 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
|
||||
|
@ -106,8 +106,9 @@ internal class RestoreCoordinator(
|
|||
* or 0 if there is no backup set available corresponding to the current device state.
|
||||
*/
|
||||
fun getCurrentRestoreSet(): Long {
|
||||
return metadataManager.getBackupToken()
|
||||
.apply { Log.i(TAG, "Got current restore set token: $this") }
|
||||
return (settingsManager.getToken() ?: 0L).apply {
|
||||
Log.i(TAG, "Got current restore set token: $this")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -8,36 +8,51 @@ import android.net.Uri
|
|||
import android.os.UserHandle
|
||||
import android.util.Log
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
|
||||
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
|
||||
|
||||
internal class BackupStorageViewModel(
|
||||
private val app: Application,
|
||||
private val backupManager: IBackupManager,
|
||||
settingsManager: SettingsManager) : StorageViewModel(app, settingsManager) {
|
||||
private val app: Application,
|
||||
private val backupManager: IBackupManager,
|
||||
private val backupCoordinator: BackupCoordinator,
|
||||
settingsManager: SettingsManager
|
||||
) : StorageViewModel(app, settingsManager) {
|
||||
|
||||
override val isRestoreOperation = false
|
||||
|
||||
override fun onLocationSet(uri: 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
|
||||
val observer = InitializationObserver()
|
||||
backupManager.initializeTransportsForUser(UserHandle.myUserId(), arrayOf(TRANSPORT_ID), observer)
|
||||
|
||||
// if storage is on USB and this is not SetupWizard, do a backup right away
|
||||
if (isUsb && !isSetupWizard) Thread {
|
||||
requestBackup(app)
|
||||
}.start()
|
||||
// initialize the new location
|
||||
backupManager.initializeTransportsForUser(
|
||||
UserHandle.myUserId(),
|
||||
arrayOf(TRANSPORT_ID),
|
||||
// if storage is on USB and this is not SetupWizard, do a backup right away
|
||||
InitializationObserver(isUsb && !isSetupWizard)
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error starting new RestoreSet", e)
|
||||
onInitializationError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private inner class InitializationObserver : IBackupObserver.Stub() {
|
||||
private inner class InitializationObserver(val requestBackup: Boolean) :
|
||||
IBackupObserver.Stub() {
|
||||
override fun onUpdate(currentBackupPackage: String, backupProgress: BackupProgress) {
|
||||
// noop
|
||||
}
|
||||
|
@ -53,12 +68,19 @@ internal class BackupStorageViewModel(
|
|||
if (status == 0) {
|
||||
// notify the UI that the location has been set
|
||||
mLocationChecked.postEvent(LocationResult())
|
||||
if (requestBackup) {
|
||||
requestBackup(app)
|
||||
}
|
||||
} else {
|
||||
// notify the UI that the location was invalid
|
||||
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
|
||||
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||
onInitializationError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onInitializationError() {
|
||||
val errorMsg = app.getString(R.string.storage_check_fragment_backup_error)
|
||||
mLocationChecked.postEvent(LocationResult(errorMsg))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -7,7 +7,6 @@ import android.app.backup.BackupTransport.TRANSPORT_OK
|
|||
import android.app.backup.RestoreDescription
|
||||
import android.app.backup.RestoreDescription.TYPE_FULL_STREAM
|
||||
import android.os.ParcelFileDescriptor
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.crypto.CipherFactoryImpl
|
||||
import com.stevesoltys.seedvault.crypto.CryptoImpl
|
||||
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.RestoreCoordinator
|
||||
import com.stevesoltys.seedvault.transport.restore.RestorePlugin
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
|
@ -186,7 +186,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
val backupDataOutput = mockk<BackupDataOutput>()
|
||||
val rInputStream = ByteArrayInputStream(bOutputStream.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
|
||||
coEvery {
|
||||
kvRestorePlugin.getInputStreamForRecord(
|
||||
|
@ -255,7 +255,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
// restore finds the backed up key and writes the decrypted value
|
||||
val backupDataOutput = mockk<BackupDataOutput>()
|
||||
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
|
||||
coEvery {
|
||||
kvRestorePlugin.getInputStreamForRecord(
|
||||
|
|
|
@ -8,7 +8,6 @@ import android.content.pm.PackageInfo
|
|||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.coAssertThrows
|
||||
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.UNKNOWN_ERROR
|
||||
import com.stevesoltys.seedvault.settings.Storage
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
|
@ -63,9 +63,18 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
private val storage = Storage(Uri.EMPTY, getRandomString(), false)
|
||||
|
||||
@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
|
||||
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
|
||||
every { metadataManager.onDeviceInitialization(token, metadataOutputStream) } just Runs
|
||||
every { kv.hasState() } returns false
|
||||
|
@ -79,9 +88,8 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `device initialization does no-op when already initialized`() = runBlocking {
|
||||
every { clock.time() } returns token
|
||||
coEvery { plugin.initializeDevice(token) } returns false
|
||||
fun `device initialization does no-op when no token available`() = runBlocking {
|
||||
every { settingsManager.getToken() } returns null
|
||||
every { kv.hasState() } returns false
|
||||
every { full.hasState() } returns false
|
||||
|
||||
|
@ -91,8 +99,8 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
|
||||
@Test
|
||||
fun `error notification when device initialization fails`() = runBlocking {
|
||||
every { clock.time() } returns token
|
||||
coEvery { plugin.initializeDevice(token) } throws IOException()
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery { plugin.initializeDevice() } throws IOException()
|
||||
every { settingsManager.getStorage() } returns storage
|
||||
every { notificationManager.onBackupError() } just Runs
|
||||
|
||||
|
@ -112,8 +120,8 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
val storage = mockk<Storage>()
|
||||
val documentFile = mockk<DocumentFile>()
|
||||
|
||||
every { clock.time() } returns token
|
||||
coEvery { plugin.initializeDevice(token) } throws IOException()
|
||||
every { settingsManager.getToken() } returns token
|
||||
coEvery { plugin.initializeDevice() } throws IOException()
|
||||
every { settingsManager.getStorage() } returns storage
|
||||
every { storage.isUsb } returns true
|
||||
every { storage.getDocumentFile(context) } returns documentFile
|
||||
|
|
|
@ -56,7 +56,7 @@ internal class KVRestoreTest : RestoreTest() {
|
|||
fun `listing records throws`() = runBlocking {
|
||||
restore.initializeState(token, packageInfo)
|
||||
|
||||
every { plugin.listRecords(token, packageInfo) } throws IOException()
|
||||
coEvery { plugin.listRecords(token, packageInfo) } throws IOException()
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, restore.getRestoreData(fileDescriptor))
|
||||
}
|
||||
|
@ -208,7 +208,7 @@ internal class KVRestoreTest : RestoreTest() {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@ import android.app.backup.RestoreDescription.TYPE_KEY_VALUE
|
|||
import android.content.pm.PackageInfo
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.coAssertThrows
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
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.settings.Storage
|
||||
import com.stevesoltys.seedvault.transport.TransportTest
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
|
@ -91,7 +91,7 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
|
||||
@Test
|
||||
fun `getCurrentRestoreSet() delegates to plugin`() {
|
||||
every { metadataManager.getBackupToken() } returns token
|
||||
every { settingsManager.getToken() } returns token
|
||||
assertEquals(token, restore.getCurrentRestoreSet())
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue