Fix issue with DocumentFileCache

This commit is contained in:
Torsten Grote 2024-08-28 11:38:02 -03:00
parent 96a3564610
commit c83e8f392e
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
7 changed files with 117 additions and 43 deletions

View file

@ -25,21 +25,23 @@ class SafBackendTest : BackendTest(), KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val settingsManager by inject<SettingsManager>() private val settingsManager by inject<SettingsManager>()
override val plugin: Backend private val safStorage = settingsManager.getSafProperties() ?: error("No SAF storage")
get() { private val safProperties = SafProperties(
val safStorage = settingsManager.getSafProperties() ?: error("No SAF storage")
val safProperties = SafProperties(
config = safStorage.config, config = safStorage.config,
name = safStorage.name, name = safStorage.name,
isUsb = safStorage.isUsb, isUsb = safStorage.isUsb,
requiresNetwork = safStorage.requiresNetwork, requiresNetwork = safStorage.requiresNetwork,
rootId = safStorage.rootId, rootId = safStorage.rootId,
) )
return SafBackend(context, safProperties, ".SeedvaultTest") override val plugin: Backend = SafBackend(context, safProperties, ".SeedvaultTest")
@Test
fun `test write list read rename delete`(): Unit = runBlocking {
testWriteListReadRenameDelete()
} }
@Test @Test
fun test(): Unit = runBlocking { fun `test remove create write file`(): Unit = runBlocking {
testWriteListReadRenameDelete() testRemoveCreateWriteFile()
} }
} }

View file

@ -15,11 +15,11 @@ import android.content.pm.PackageInfo
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.isOutOfSpace
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.header.getADForKV
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.isOutOfSpace
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.calyxos.seedvault.core.backends.LegacyAppBackupFile

View file

@ -0,0 +1 @@
org.slf4j.simpleLogger.defaultLogLevel=debug

View file

@ -6,14 +6,12 @@
package org.calyxos.seedvault.core.backends package org.calyxos.seedvault.core.backends
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import at.bitfire.dav4jvm.exception.HttpException
import org.calyxos.seedvault.core.toHexString import org.calyxos.seedvault.core.toHexString
import org.junit.Assert.assertArrayEquals
import kotlin.random.Random import kotlin.random.Random
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.fail
@VisibleForTesting @VisibleForTesting
public abstract class BackendTest { public abstract class BackendTest {
@ -21,12 +19,9 @@ public abstract class BackendTest {
public abstract val plugin: Backend public abstract val plugin: Backend
protected suspend fun testWriteListReadRenameDelete() { protected suspend fun testWriteListReadRenameDelete() {
try {
plugin.removeAll() plugin.removeAll()
} catch (e: HttpException) {
if (e.code != 404) fail(e.message, e)
}
val androidId = "0123456789abcdef"
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val bytes1 = Random.nextBytes(1337) val bytes1 = Random.nextBytes(1337)
val bytes2 = Random.nextBytes(1337 * 8) val bytes2 = Random.nextBytes(1337 * 8)
@ -34,7 +29,7 @@ public abstract class BackendTest {
it.write(bytes1) it.write(bytes1)
} }
plugin.save(FileBackupFileType.Snapshot("0123456789abcdef", now)).use { plugin.save(FileBackupFileType.Snapshot(androidId, now)).use {
it.write(bytes2) it.write(bytes2)
} }
@ -56,13 +51,13 @@ public abstract class BackendTest {
assertNotNull(metadata) assertNotNull(metadata)
assertNotNull(snapshot) assertNotNull(snapshot)
assertArrayEquals(bytes1, plugin.load(metadata as FileHandle).readAllBytes()) assertContentEquals(bytes1, plugin.load(metadata as FileHandle).readAllBytes())
assertArrayEquals(bytes2, plugin.load(snapshot as FileHandle).readAllBytes()) assertContentEquals(bytes2, plugin.load(snapshot as FileHandle).readAllBytes())
val blobName = Random.nextBytes(32).toHexString() val blobName = Random.nextBytes(32).toHexString()
var blob: FileBackupFileType.Blob? = null var blob: FileBackupFileType.Blob? = null
val bytes3 = Random.nextBytes(1337 * 16) val bytes3 = Random.nextBytes(1337 * 16)
plugin.save(FileBackupFileType.Blob("0123456789abcdef", blobName)).use { plugin.save(FileBackupFileType.Blob(androidId, blobName)).use {
it.write(bytes3) it.write(bytes3)
} }
plugin.list( plugin.list(
@ -77,7 +72,7 @@ public abstract class BackendTest {
} }
} }
assertNotNull(blob) assertNotNull(blob)
assertArrayEquals(bytes3, plugin.load(blob as FileHandle).readAllBytes()) assertContentEquals(bytes3, plugin.load(blob as FileHandle).readAllBytes())
// try listing with top-level folder, should find two files of FileBackupFileType in there // try listing with top-level folder, should find two files of FileBackupFileType in there
var numFiles = 0 var numFiles = 0
@ -92,7 +87,7 @@ public abstract class BackendTest {
plugin.remove(snapshot as FileHandle) plugin.remove(snapshot as FileHandle)
// rename snapshots // rename snapshots
val snapshotNewFolder = TopLevelFolder("0123456789abcdee.sv") val snapshotNewFolder = TopLevelFolder("a123456789abcdef.sv")
plugin.rename(snapshot!!.topLevelFolder, snapshotNewFolder) plugin.rename(snapshot!!.topLevelFolder, snapshotNewFolder)
// rename to existing folder should fail // rename to existing folder should fail
@ -105,4 +100,20 @@ public abstract class BackendTest {
plugin.remove(snapshotNewFolder) plugin.remove(snapshotNewFolder)
} }
protected suspend fun testRemoveCreateWriteFile() {
val now = System.currentTimeMillis()
val blob = LegacyAppBackupFile.Blob(now, Random.nextBytes(32).toHexString())
val bytes = Random.nextBytes(2342)
plugin.remove(blob)
try {
plugin.save(blob).use {
it.write(bytes)
}
assertContentEquals(bytes, plugin.load(blob as FileHandle).readAllBytes())
} finally {
plugin.remove(blob)
}
}
} }

View file

@ -11,6 +11,7 @@ import org.calyxos.seedvault.core.backends.FileBackupFileType
import org.calyxos.seedvault.core.backends.FileHandle import org.calyxos.seedvault.core.backends.FileHandle
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import org.calyxos.seedvault.core.backends.TopLevelFolder import org.calyxos.seedvault.core.backends.TopLevelFolder
import java.util.concurrent.ConcurrentHashMap
internal class DocumentFileCache( internal class DocumentFileCache(
private val context: Context, private val context: Context,
@ -18,7 +19,7 @@ internal class DocumentFileCache(
private val root: String, private val root: String,
) { ) {
private val cache = mutableMapOf<String, DocumentFile>() private val cache = ConcurrentHashMap<String, DocumentFile>()
internal suspend fun getRootFile(): DocumentFile { internal suspend fun getRootFile(): DocumentFile {
return cache.getOrPut(root) { return cache.getOrPut(root) {
@ -26,24 +27,53 @@ internal class DocumentFileCache(
} }
} }
internal suspend fun getFile(fh: FileHandle): DocumentFile = when (fh) { internal suspend fun getOrCreateFile(fh: FileHandle): DocumentFile = when (fh) {
is TopLevelFolder -> cache.getOrPut("$root/${fh.relativePath}") { is TopLevelFolder -> cache.getOrPut("$root/${fh.relativePath}") {
getRootFile().getOrCreateDirectory(context, fh.name) getRootFile().getOrCreateDirectory(context, fh.name)
} }
is LegacyAppBackupFile -> cache.getOrPut("$root/${fh.relativePath}") { is LegacyAppBackupFile -> cache.getOrPut("$root/${fh.relativePath}") {
getFile(fh.topLevelFolder).getOrCreateFile(context, fh.name) getOrCreateFile(fh.topLevelFolder).getOrCreateFile(context, fh.name)
} }
is FileBackupFileType.Blob -> { is FileBackupFileType.Blob -> {
val subFolderName = fh.name.substring(0, 2) val subFolderName = fh.name.substring(0, 2)
cache.getOrPut("$root/${fh.topLevelFolder.name}/$subFolderName") { cache.getOrPut("$root/${fh.topLevelFolder.name}/$subFolderName") {
getFile(fh.topLevelFolder).getOrCreateDirectory(context, subFolderName) getOrCreateFile(fh.topLevelFolder).getOrCreateDirectory(context, subFolderName)
}.getOrCreateFile(context, fh.name) }.getOrCreateFile(context, fh.name)
} }
is FileBackupFileType.Snapshot -> { is FileBackupFileType.Snapshot -> {
getFile(fh.topLevelFolder).getOrCreateFile(context, fh.name) getOrCreateFile(fh.topLevelFolder).getOrCreateFile(context, fh.name)
} }
} }
internal suspend fun getFile(fh: FileHandle): DocumentFile? = when (fh) {
is TopLevelFolder -> cache.getOrElse("$root/${fh.relativePath}") {
getRootFile().findFileBlocking(context, fh.name)
}
is LegacyAppBackupFile -> cache.getOrElse("$root/${fh.relativePath}") {
getFile(fh.topLevelFolder)?.findFileBlocking(context, fh.name)
}
is FileBackupFileType.Blob -> {
val subFolderName = fh.name.substring(0, 2)
cache.getOrElse("$root/${fh.topLevelFolder.name}/$subFolderName") {
getFile(fh.topLevelFolder)?.findFileBlocking(context, subFolderName)
}?.findFileBlocking(context, fh.name)
}
is FileBackupFileType.Snapshot -> {
getFile(fh.topLevelFolder)?.findFileBlocking(context, fh.name)
}
}
internal fun removeFromCache(fh: FileHandle) {
cache.remove("$root/${fh.relativePath}")
}
internal fun clearAll() {
cache.clear()
}
} }

View file

@ -14,6 +14,7 @@ import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import android.provider.DocumentsContract.renameDocument import android.provider.DocumentsContract.renameDocument
import androidx.core.database.getIntOrNull import androidx.core.database.getIntOrNull
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT
@ -37,6 +38,8 @@ import kotlin.reflect.KClass
internal const val AUTHORITY_STORAGE = "com.android.externalstorage.documents" internal const val AUTHORITY_STORAGE = "com.android.externalstorage.documents"
internal const val ROOT_ID_DEVICE = "primary" internal const val ROOT_ID_DEVICE = "primary"
private const val DEBUG_LOG = true
public class SafBackend( public class SafBackend(
private val appContext: Context, private val appContext: Context,
private val safProperties: SafProperties, private val safProperties: SafProperties,
@ -52,10 +55,12 @@ public class SafBackend(
private val cache = DocumentFileCache(context, safProperties.getDocumentFile(context), root) private val cache = DocumentFileCache(context, safProperties.getDocumentFile(context), root)
override suspend fun test(): Boolean { override suspend fun test(): Boolean {
log.debugLog { "test()" }
return cache.getRootFile().isDirectory return cache.getRootFile().isDirectory
} }
override suspend fun getFreeSpace(): Long? { override suspend fun getFreeSpace(): Long? {
log.debugLog { "getFreeSpace()" }
val rootId = safProperties.rootId ?: return null val rootId = safProperties.rootId ?: return null
val authority = safProperties.uri.authority val authority = safProperties.uri.authority
// using DocumentsContract#buildRootUri(String, String) with rootId directly doesn't work // using DocumentsContract#buildRootUri(String, String) with rootId directly doesn't work
@ -82,12 +87,14 @@ public class SafBackend(
} }
override suspend fun save(handle: FileHandle): OutputStream { override suspend fun save(handle: FileHandle): OutputStream {
val file = cache.getFile(handle) log.debugLog { "save($handle)" }
val file = cache.getOrCreateFile(handle)
return file.getOutputStream(context.contentResolver) return file.getOutputStream(context.contentResolver)
} }
override suspend fun load(handle: FileHandle): InputStream { override suspend fun load(handle: FileHandle): InputStream {
val file = cache.getFile(handle) log.debugLog { "load($handle)" }
val file = cache.getOrCreateFile(handle)
return file.getInputStream(context.contentResolver) return file.getInputStream(context.contentResolver)
} }
@ -101,10 +108,12 @@ public class SafBackend(
if (LegacyAppBackupFile.IconsFile::class in fileTypes) throw UnsupportedOperationException() if (LegacyAppBackupFile.IconsFile::class in fileTypes) throw UnsupportedOperationException()
if (LegacyAppBackupFile.Blob::class in fileTypes) throw UnsupportedOperationException() if (LegacyAppBackupFile.Blob::class in fileTypes) throw UnsupportedOperationException()
log.debugLog { "list($topLevelFolder, $fileTypes)" }
val folder = if (topLevelFolder == null) { val folder = if (topLevelFolder == null) {
cache.getRootFile() cache.getRootFile()
} else { } else {
cache.getFile(topLevelFolder) cache.getOrCreateFile(topLevelFolder)
} }
// limit depth based on wanted types and if top-level folder is given // limit depth based on wanted types and if top-level folder is given
var depth = if (FileBackupFileType.Blob::class in fileTypes) 3 else 2 var depth = if (FileBackupFileType.Blob::class in fileTypes) 3 else 2
@ -161,12 +170,16 @@ public class SafBackend(
} }
override suspend fun remove(handle: FileHandle) { override suspend fun remove(handle: FileHandle) {
val file = cache.getFile(handle) log.debugLog { "remove($handle)" }
cache.getFile(handle)?.let { file ->
if (!file.delete()) throw IOException("could not delete ${handle.relativePath}") if (!file.delete()) throw IOException("could not delete ${handle.relativePath}")
cache.removeFromCache(handle)
}
} }
override suspend fun rename(from: TopLevelFolder, to: TopLevelFolder) { override suspend fun rename(from: TopLevelFolder, to: TopLevelFolder) {
val fromFile = cache.getFile(from) log.debugLog { "rename($from, ${to.name})" }
val fromFile = cache.getOrCreateFile(from)
// don't use fromFile.renameTo(to.name) as that creates "${to.name} (1)" // don't use fromFile.renameTo(to.name) as that creates "${to.name} (1)"
val newUri = renameDocument(context.contentResolver, fromFile.uri, to.name) val newUri = renameDocument(context.contentResolver, fromFile.uri, to.name)
?: throw IOException("could not rename ${from.relativePath}") ?: throw IOException("could not rename ${from.relativePath}")
@ -179,16 +192,28 @@ public class SafBackend(
} }
override suspend fun removeAll() { override suspend fun removeAll() {
cache.getRootFile().listFilesBlocking(context).forEach { log.debugLog { "removeAll()" }
it.delete() try {
cache.getRootFile().listFilesBlocking(context).forEach { file ->
log.debugLog { " remove ${file.uri}" }
file.delete()
}
} finally {
cache.clearAll()
} }
} }
override val providerPackageName: String? by lazy { override val providerPackageName: String? by lazy {
log.debugLog { "providerPackageName" }
val authority = safProperties.uri.authority ?: return@lazy null val authority = safProperties.uri.authority ?: return@lazy null
val providerInfo = context.packageManager.resolveContentProvider(authority, 0) val providerInfo = context.packageManager.resolveContentProvider(authority, 0)
?: return@lazy null ?: return@lazy null
log.debugLog { " ${providerInfo.packageName}" }
providerInfo.packageName providerInfo.packageName
} }
} }
private inline fun KLogger.debugLog(crossinline block: () -> String) {
if (DEBUG_LOG) debug { block() }
}

View file

@ -17,4 +17,9 @@ public class WebDavBackendTest : BackendTest() {
public fun `test write, list, read, rename, delete`(): Unit = runBlocking { public fun `test write, list, read, rename, delete`(): Unit = runBlocking {
testWriteListReadRenameDelete() testWriteListReadRenameDelete()
} }
@Test
public fun `test remove, create, write file`(): Unit = runBlocking {
testRemoveCreateWriteFile()
}
} }