Ensure root folder exists when using storage
We use the same root folder for app and files backup. App backup usually creates the root folder, but if only storage backup is used, it will be missing and needs to be created.
This commit is contained in:
parent
e6e65d0dd1
commit
4f2ead66a5
12 changed files with 141 additions and 22 deletions
|
@ -38,6 +38,7 @@ const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
|
|||
@OptIn(DelicateCoroutinesApi::class)
|
||||
internal abstract class WebDavStorage(
|
||||
webDavConfig: WebDavConfig,
|
||||
root: String = DIRECTORY_ROOT,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
@ -61,7 +62,7 @@ internal abstract class WebDavStorage(
|
|||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
protected val url = "${webDavConfig.url}/$DIRECTORY_ROOT"
|
||||
protected val url = "${webDavConfig.url}/$root"
|
||||
|
||||
@Throws(IOException::class)
|
||||
protected suspend fun getOutputStream(location: HttpUrl): OutputStream {
|
||||
|
|
|
@ -24,7 +24,8 @@ import kotlin.coroutines.suspendCoroutine
|
|||
internal class WebDavStoragePlugin(
|
||||
context: Context,
|
||||
webDavConfig: WebDavConfig,
|
||||
) : WebDavStorage(webDavConfig), StoragePlugin {
|
||||
root: String = DIRECTORY_ROOT,
|
||||
) : WebDavStorage(webDavConfig, root), StoragePlugin {
|
||||
|
||||
@Throws(IOException::class)
|
||||
override suspend fun startNewRestoreSet(token: Long) {
|
||||
|
@ -39,6 +40,20 @@ internal class WebDavStoragePlugin(
|
|||
override suspend fun initializeDevice() {
|
||||
// TODO does it make sense to delete anything
|
||||
// when [startNewRestoreSet] is always called first? Maybe unify both calls?
|
||||
val location = url.toHttpUrl()
|
||||
val davCollection = DavCollection(okHttpClient, location)
|
||||
|
||||
try {
|
||||
davCollection.head { response ->
|
||||
debugLog { "Root exists: $response" }
|
||||
}
|
||||
} catch (e: NotFoundException) {
|
||||
val response = davCollection.createFolder()
|
||||
debugLog { "initializeDevice() = $response" }
|
||||
} catch (e: Exception) {
|
||||
if (e is IOException) throw e
|
||||
else throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
|
|
|
@ -8,6 +8,7 @@ import at.bitfire.dav4jvm.property.DisplayName
|
|||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
|
||||
import com.stevesoltys.seedvault.plugins.webdav.DIRECTORY_ROOT
|
||||
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
|
||||
import com.stevesoltys.seedvault.plugins.webdav.WebDavStorage
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
|
@ -30,7 +31,8 @@ internal class WebDavStoragePlugin(
|
|||
*/
|
||||
androidId: String,
|
||||
webDavConfig: WebDavConfig,
|
||||
) : WebDavStorage(webDavConfig), StoragePlugin {
|
||||
root: String = DIRECTORY_ROOT,
|
||||
) : WebDavStorage(webDavConfig, root), StoragePlugin {
|
||||
|
||||
/**
|
||||
* The folder name is our user ID plus .sv extension (for SeedVault).
|
||||
|
@ -39,6 +41,24 @@ internal class WebDavStoragePlugin(
|
|||
*/
|
||||
private val folder: String = "$androidId.sv"
|
||||
|
||||
@Throws(IOException::class)
|
||||
override suspend fun init() {
|
||||
val location = url.toHttpUrl()
|
||||
val davCollection = DavCollection(okHttpClient, location)
|
||||
|
||||
try {
|
||||
davCollection.head { response ->
|
||||
debugLog { "Root exists: $response" }
|
||||
}
|
||||
} catch (e: NotFoundException) {
|
||||
val response = davCollection.createFolder()
|
||||
debugLog { "init() = $response" }
|
||||
} catch (e: Exception) {
|
||||
if (e is IOException) throw e
|
||||
else throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override suspend fun getAvailableChunkIds(): List<String> {
|
||||
val location = "$url/$folder".toHttpUrl()
|
||||
|
@ -211,18 +231,13 @@ internal class WebDavStoragePlugin(
|
|||
val match = snapshotRegex.matchEntire(response.hrefName())
|
||||
if (match != null) {
|
||||
val timestamp = match.groupValues[1].toLong()
|
||||
val folderName =
|
||||
response.href.pathSegments[response.href.pathSegments.size - 2]
|
||||
val storedSnapshot = StoredSnapshot(folderName, timestamp)
|
||||
val storedSnapshot = StoredSnapshot(folder, timestamp)
|
||||
snapshots.add(storedSnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "getCurrentBackupSnapshots took $duration")
|
||||
} catch (e: NotFoundException) {
|
||||
debugLog { "Folder not found: $location" }
|
||||
davCollection.createFolder()
|
||||
} catch (e: Exception) {
|
||||
if (e is IOException) throw e
|
||||
else throw IOException("Error getting current snapshots: ", e)
|
||||
|
|
|
@ -103,8 +103,7 @@ internal class RecoveryCodeViewModel(
|
|||
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
// remove old storage snapshots and clear cache
|
||||
storageBackup.deleteAllSnapshots()
|
||||
storageBackup.clearCache()
|
||||
storageBackup.init()
|
||||
try {
|
||||
// initialize the new location
|
||||
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
|
||||
|
|
|
@ -45,8 +45,7 @@ internal class BackupStorageViewModel(
|
|||
// remove old storage snapshots and clear cache
|
||||
// TODO is this needed? It also does create all 255 chunk folders which takes time
|
||||
// pass a flag to getCurrentBackupSnapshots() to not create missing folders?
|
||||
storageBackup.deleteAllSnapshots()
|
||||
storageBackup.clearCache()
|
||||
storageBackup.init()
|
||||
try {
|
||||
// initialize the new location (if backups are enabled)
|
||||
if (backupManager.isBackupEnabled) {
|
||||
|
|
|
@ -12,6 +12,7 @@ import org.junit.Test
|
|||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Assertions.fail
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
@ -34,15 +35,17 @@ internal class WebDavStoragePluginTest : TransportTest() {
|
|||
val token = System.currentTimeMillis()
|
||||
val metadata = getRandomByteArray()
|
||||
|
||||
// need to initialize, to have root .SeedVaultAndroidBackup folder
|
||||
plugin.initializeDevice()
|
||||
plugin.startNewRestoreSet(token)
|
||||
|
||||
// initially, we don't have any backups
|
||||
assertEquals(emptySet<EncryptedMetadata>(), plugin.getAvailableBackups()?.toSet())
|
||||
|
||||
// and no data
|
||||
assertFalse(plugin.hasData(token, FILE_BACKUP_METADATA))
|
||||
|
||||
// start a new restore set, initialize it and write out the metadata file
|
||||
plugin.startNewRestoreSet(token)
|
||||
plugin.initializeDevice()
|
||||
// write out the metadata file
|
||||
plugin.getOutputStream(token, FILE_BACKUP_METADATA).use {
|
||||
it.write(metadata)
|
||||
}
|
||||
|
@ -85,4 +88,18 @@ internal class WebDavStoragePluginTest : TransportTest() {
|
|||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test missing root dir`() = runBlocking {
|
||||
val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig(), getRandomString())
|
||||
|
||||
assertNull(plugin.getAvailableBackups())
|
||||
|
||||
assertFalse(plugin.hasData(42L, "foo"))
|
||||
|
||||
assertThrows<IOException> {
|
||||
plugin.removeData(42L, "foo")
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ package com.stevesoltys.seedvault.storage
|
|||
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.getRandomByteArray
|
||||
import com.stevesoltys.seedvault.getRandomString
|
||||
import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupTest
|
||||
import io.mockk.mockk
|
||||
|
@ -15,6 +16,8 @@ import org.calyxos.backup.storage.api.StoredSnapshot
|
|||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import java.io.IOException
|
||||
|
||||
internal class WebDavStoragePluginTest : BackupTest() {
|
||||
|
||||
|
@ -28,6 +31,9 @@ internal class WebDavStoragePluginTest : BackupTest() {
|
|||
val chunkId1 = getRandomByteArray(32).toHexString()
|
||||
val chunkBytes1 = getRandomByteArray()
|
||||
|
||||
// init to create root folder
|
||||
plugin.init()
|
||||
|
||||
// first we don't have any chunks
|
||||
assertEquals(emptyList<String>(), plugin.getAvailableChunkIds())
|
||||
|
||||
|
@ -55,6 +61,9 @@ internal class WebDavStoragePluginTest : BackupTest() {
|
|||
fun `test snapshots`() = runBlocking {
|
||||
val snapshotBytes = getRandomByteArray()
|
||||
|
||||
// init to create root folder
|
||||
plugin.init()
|
||||
|
||||
// first we don't have any snapshots
|
||||
assertEquals(emptyList<StoredSnapshot>(), plugin.getCurrentBackupSnapshots())
|
||||
assertEquals(emptyList<StoredSnapshot>(), plugin.getBackupSnapshotsForRestore())
|
||||
|
@ -98,6 +107,45 @@ internal class WebDavStoragePluginTest : BackupTest() {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test missing root dir`() = runBlocking {
|
||||
val plugin = WebDavStoragePlugin(
|
||||
keyManager = keyManager,
|
||||
androidId = "foo",
|
||||
webDavConfig = WebDavTestConfig.getConfig(),
|
||||
root = getRandomString(),
|
||||
)
|
||||
|
||||
assertThrows<IOException> {
|
||||
plugin.getCurrentBackupSnapshots()
|
||||
}
|
||||
assertThrows<IOException> {
|
||||
plugin.getBackupSnapshotsForRestore()
|
||||
}
|
||||
assertThrows<IOException> {
|
||||
plugin.getAvailableChunkIds()
|
||||
}
|
||||
assertThrows<IOException> {
|
||||
plugin.deleteChunks(listOf("foo"))
|
||||
}
|
||||
assertThrows<IOException> {
|
||||
plugin.deleteBackupSnapshot(snapshot)
|
||||
}
|
||||
assertThrows<IOException> {
|
||||
plugin.getBackupSnapshotOutputStream(snapshot.timestamp).close()
|
||||
}
|
||||
assertThrows<IOException> {
|
||||
plugin.getBackupSnapshotInputStream(snapshot).use { it.readAllBytes() }
|
||||
}
|
||||
assertThrows<IOException> {
|
||||
plugin.getChunkOutputStream("foo").close()
|
||||
}
|
||||
assertThrows<IOException> {
|
||||
plugin.getChunkInputStream(snapshot, "foo").use { it.readAllBytes() }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
|
||||
|
|
|
@ -73,6 +73,7 @@ internal abstract class TransportTest {
|
|||
mockkStatic(Log::class)
|
||||
val logTagSlot = slot<String>()
|
||||
val logMsgSlot = slot<String>()
|
||||
val logExSlot = slot<Throwable>()
|
||||
every { Log.v(any(), any()) } returns 0
|
||||
every { Log.d(capture(logTagSlot), capture(logMsgSlot)) } answers {
|
||||
println("${logTagSlot.captured} - ${logMsgSlot.captured}")
|
||||
|
@ -83,7 +84,11 @@ internal abstract class TransportTest {
|
|||
every { Log.w(any(), ofType(String::class)) } returns 0
|
||||
every { Log.w(any(), ofType(String::class), any()) } returns 0
|
||||
every { Log.e(any(), any()) } returns 0
|
||||
every { Log.e(any(), any(), any()) } returns 0
|
||||
every { Log.e(capture(logTagSlot), capture(logMsgSlot), capture(logExSlot)) } answers {
|
||||
println("${logTagSlot.captured} - ${logMsgSlot.captured} ${logExSlot.captured}")
|
||||
logExSlot.captured.printStackTrace()
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -32,11 +32,11 @@ import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
|
|||
|
||||
private val logEmptyState = """
|
||||
Press the button below to simulate a backup. Your files won't be changed and not uploaded anywhere. This is just to test code for a future real backup.
|
||||
|
||||
|
||||
Please come back to this app from time to time and run a backup again to see if it correctly identifies files that were added/changed.
|
||||
|
||||
|
||||
Note that after updating this app, it might need to re-backup all files again.
|
||||
|
||||
|
||||
Thanks for testing!
|
||||
""".trimIndent()
|
||||
private const val TAG = "MainViewModel"
|
||||
|
@ -98,8 +98,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
|
|||
fun setBackupLocation(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
storageBackup.deleteAllSnapshots()
|
||||
storageBackup.clearCache()
|
||||
storageBackup.init()
|
||||
}
|
||||
}
|
||||
settingsManager.setBackupLocation(uri)
|
||||
|
|
|
@ -104,6 +104,16 @@ public class StorageBackup(
|
|||
list.joinToString(", ", limit = 5)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the storage is set-up to receive backups and deletes all snapshots
|
||||
* (see [deleteAllSnapshots]) as well as clears local cache (see [clearCache]).
|
||||
*/
|
||||
public suspend fun init() {
|
||||
plugin.init()
|
||||
deleteAllSnapshots()
|
||||
clearCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Run this on a new storage location to ensure that there are no old snapshots
|
||||
* (potentially encrypted with an old key) laying around.
|
||||
|
|
|
@ -13,6 +13,13 @@ import javax.crypto.SecretKey
|
|||
|
||||
public interface StoragePlugin {
|
||||
|
||||
/**
|
||||
* Prepares the storage location for storing backups.
|
||||
* Call this before using the [StoragePlugin] for the first time.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
public suspend fun init()
|
||||
|
||||
/**
|
||||
* Called before starting a backup run to ensure that all cached chunks are still available.
|
||||
* Plugins should use this opportunity
|
||||
|
|
|
@ -75,6 +75,10 @@ public abstract class SafStoragePlugin(
|
|||
return "$timestamp.SeedSnap"
|
||||
}
|
||||
|
||||
override suspend fun init() {
|
||||
// no-op as we are getting [root] created from super class
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override suspend fun getAvailableChunkIds(): List<String> {
|
||||
val folder = folder ?: return emptyList()
|
||||
|
|
Loading…
Reference in a new issue