1
0
Fork 0

Use new SafBackend in DocumentsProviderStoragePlugin

This commit is contained in:
Torsten Grote 2024-08-26 15:54:48 -03:00
parent 8c05ccc39d
commit 5bb599e528
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
18 changed files with 188 additions and 1000 deletions
app/src
core
build.gradle.kts
src/main/java/org/calyxos/seedvault/core/backends/saf
storage
demo/src/main/java/de/grobox/storagebackuptester/plugin
lib
build.gradle.kts
src/main/java/org/calyxos/backup/storage/plugin/saf

View file

@ -5,18 +5,15 @@
package com.stevesoltys.seedvault
import android.net.Uri
import androidx.test.core.content.pm.PackageInfoBuilder
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderLegacyPlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.plugins.saf.deleteContents
import com.stevesoltys.seedvault.settings.SettingsManager
import io.mockk.every
import io.mockk.mockk
@ -24,7 +21,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
@ -46,7 +42,7 @@ class PluginTest : KoinComponent {
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
)
private val storagePlugin: StoragePlugin<Uri> = DocumentsProviderStoragePlugin(context, storage)
private val storagePlugin = DocumentsProviderStoragePlugin(context, storage.safStorage)
@Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
@ -60,13 +56,12 @@ class PluginTest : KoinComponent {
@Before
fun setup() = runBlocking {
every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage()
storage.rootBackupDir?.deleteContents(context)
?: error("Select a storage location in the app first!")
storagePlugin.removeAll()
}
@After
fun tearDown() = runBlocking {
storage.rootBackupDir?.deleteContents(context)
storagePlugin.removeAll()
Unit
}
@ -124,9 +119,6 @@ class PluginTest : KoinComponent {
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
.writeAndClose(getRandomByteArray())
assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
// ensure that the new backup dir exist
assertTrue(storage.currentSetDir!!.exists())
}
@Test

View file

@ -1,226 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.saf
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.os.Bundle
import android.provider.DocumentsContract.EXTRA_LOADING
import androidx.documentfile.provider.DocumentFile
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.stevesoltys.seedvault.assertReadEquals
import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomBase64
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.writeAndClose
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import java.io.IOException
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@MediumTest
class DocumentsStorageTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val settingsManager by inject<SettingsManager>()
private val storage = DocumentsStorage(
appContext = context,
settingsManager = settingsManager,
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
)
private val filename = getRandomBase64()
private lateinit var file: DocumentFile
@Before
fun setup() = runBlocking {
assertNotNull("Select a storage location in the app first!", storage.rootBackupDir)
file = storage.rootBackupDir?.createOrGetFile(context, filename)
?: error("Could not create test file")
}
@After
fun tearDown() {
file.delete()
}
@Test
fun testWritingAndReadingFile() {
// write to output stream
val outputStream = storage.getOutputStream(file)
val content = ByteArray(1337).apply { Random.nextBytes(this) }
outputStream.write(content)
outputStream.flush()
outputStream.close()
// read written data from input stream
val inputStream = storage.getInputStream(file)
val readContent = inputStream.readBytes()
inputStream.close()
assertArrayEquals(content, readContent)
// write smaller content to same file
val outputStream2 = storage.getOutputStream(file)
val content2 = ByteArray(42).apply { Random.nextBytes(this) }
outputStream2.write(content2)
outputStream2.flush()
outputStream2.close()
// read written data from input stream
val inputStream2 = storage.getInputStream(file)
val readContent2 = inputStream2.readBytes()
inputStream2.close()
assertArrayEquals(content2, readContent2)
}
@Test
fun testFindFile() = runBlocking(Dispatchers.IO) {
val foundFile = storage.rootBackupDir!!.findFileBlocking(context, file.name!!)
assertNotNull(foundFile)
assertEquals(filename, foundFile!!.name)
assertEquals(storage.rootBackupDir!!.uri, foundFile.parentFile?.uri)
}
@Test
fun testCreateFile() {
// create test file
val dir = storage.rootBackupDir!!
val createdFile = dir.createFile("text", getRandomBase64())
assertNotNull(createdFile)
assertNotNull(createdFile!!.name)
// write some data into it
val data = getRandomByteArray()
context.contentResolver.openOutputStream(createdFile.uri)!!.writeAndClose(data)
// data should still be there
assertReadEquals(data, context.contentResolver.openInputStream(createdFile.uri))
// delete again
createdFile.delete()
assertFalse(createdFile.exists())
}
@Test
fun testCreateTwoFiles() = runBlocking {
val mimeType = "application/octet-stream"
val dir = storage.rootBackupDir!!
// create test file
val name1 = getRandomBase64(Random.nextInt(1, 10))
val file1 = requireNotNull(dir.createFile(mimeType, name1))
assertTrue(file1.exists())
assertEquals(name1, file1.name)
assertEquals(0L, file1.length())
assertReadEquals(getRandomByteArray(0), context.contentResolver.openInputStream(file1.uri))
// write some data into it
val data1 = getRandomByteArray(5 * 1024 * 1024)
context.contentResolver.openOutputStream(file1.uri)!!.writeAndClose(data1)
assertEquals(data1.size.toLong(), file1.length())
// data should still be there
assertReadEquals(data1, context.contentResolver.openInputStream(file1.uri))
// create test file
val name2 = getRandomBase64(Random.nextInt(1, 10))
val file2 = requireNotNull(dir.createFile(mimeType, name2))
assertTrue(file2.exists())
assertEquals(name2, file2.name)
// write some data into it
val data2 = getRandomByteArray(12 * 1024 * 1024)
context.contentResolver.openOutputStream(file2.uri)!!.writeAndClose(data2)
assertEquals(data2.size.toLong(), file2.length())
// data should still be there
assertReadEquals(data2, context.contentResolver.openInputStream(file2.uri))
// delete files again
file1.delete()
file2.delete()
assertFalse(file1.exists())
assertFalse(file2.exists())
}
@Test
fun testGetLoadedCursor() = runBlocking {
// empty cursor extras are like not loading, returns same cursor right away
val cursor1: Cursor = mockk()
every { cursor1.extras } returns Bundle()
assertEquals(cursor1, getLoadedCursor { cursor1 })
// explicitly not loading, returns same cursor right away
val cursor2: Cursor = mockk()
every { cursor2.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, false) }
assertEquals(cursor2, getLoadedCursor { cursor2 })
// loading cursor registers content observer, times out and closes cursor
val cursor3: Cursor = mockk()
every { cursor3.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
every { cursor3.registerContentObserver(any()) } just Runs
every { cursor3.close() } just Runs
coAssertThrows(TimeoutCancellationException::class.java) {
getLoadedCursor(1000) { cursor3 }
}
verify { cursor3.registerContentObserver(any()) }
verify { cursor3.close() } // ensure that cursor gets closed
// loading cursor registers content observer, but re-query fails
val cursor4: Cursor = mockk()
val observer4 = slot<ContentObserver>()
val query: () -> Cursor? = { if (observer4.isCaptured) null else cursor4 }
every { cursor4.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
every { cursor4.registerContentObserver(capture(observer4)) } answers {
observer4.captured.onChange(false, Uri.parse("foo://bar"))
}
every { cursor4.close() } just Runs
coAssertThrows(IOException::class.java) {
getLoadedCursor(10_000, query)
}
assertTrue(observer4.isCaptured)
verify { cursor4.close() } // ensure that cursor gets closed
// loading cursor registers content observer, re-queries and returns new result
val cursor5: Cursor = mockk()
val result5: Cursor = mockk()
val observer5 = slot<ContentObserver>()
val query5: () -> Cursor? = { if (observer5.isCaptured) result5 else cursor5 }
every { cursor5.extras } returns Bundle().apply { putBoolean(EXTRA_LOADING, true) }
every { cursor5.registerContentObserver(capture(observer5)) } answers {
observer5.captured.onChange(false, null)
}
every { cursor5.close() } just Runs
assertEquals(result5, getLoadedCursor(10_000, query5))
assertTrue(observer5.isCaptured)
verify { cursor5.close() } // ensure that initial cursor got closed
}
}

View file

@ -11,7 +11,6 @@ import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.SafFactory
import com.stevesoltys.seedvault.plugins.webdav.WebDavFactory
import com.stevesoltys.seedvault.settings.SettingsManager
@ -51,9 +50,8 @@ class StoragePluginManager(
when (settingsManager.storagePluginType) {
StoragePluginType.SAF -> {
val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage saved")
val documentsStorage = DocumentsStorage(context, settingsManager, safStorage)
mAppPlugin = safFactory.createAppStoragePlugin(safStorage, documentsStorage)
mFilesPlugin = safFactory.createFilesStoragePlugin(safStorage, documentsStorage)
mAppPlugin = safFactory.createAppStoragePlugin(safStorage)
mFilesPlugin = safFactory.createFilesStoragePlugin(safStorage)
mStorageProperties = safStorage
}

View file

@ -11,7 +11,7 @@ import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val storagePluginModuleSaf = module {
single { SafFactory(androidContext(), get(), get()) }
single { SafFactory(androidContext()) }
single { SafHandler(androidContext(), get(), get(), get()) }
@Suppress("Deprecation")

View file

@ -6,25 +6,15 @@
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import android.os.StatFs
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import android.util.Log
import androidx.core.database.getIntOrNull
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
import com.stevesoltys.seedvault.plugins.tokenRegex
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import com.stevesoltys.seedvault.ui.storage.ROOT_ID_DEVICE
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
import java.io.FileNotFoundException
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import org.calyxos.seedvault.core.backends.saf.SafBackend
import org.calyxos.seedvault.core.backends.saf.SafConfig
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@ -32,156 +22,92 @@ import java.io.OutputStream
private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
internal class DocumentsProviderStoragePlugin(
private val appContext: Context,
private val storage: DocumentsStorage,
appContext: Context,
safStorage: SafStorage,
root: String = DIRECTORY_ROOT,
) : StoragePlugin<Uri> {
/**
* Attention: This context might be from a different user. Use with care.
*/
private val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
private val packageManager: PackageManager = appContext.packageManager
private val safConfig = SafConfig(
config = safStorage.config,
name = safStorage.name,
isUsb = safStorage.isUsb,
requiresNetwork = safStorage.requiresNetwork,
rootId = safStorage.rootId,
)
private val delegate: SafBackend = SafBackend(appContext, safConfig, root)
override suspend fun test(): Boolean {
val dir = storage.rootBackupDir
return dir != null && dir.exists()
return delegate.test()
}
override suspend fun getFreeSpace(): Long? {
val rootId = storage.safStorage.rootId ?: return null
val authority = storage.safStorage.uri.authority
// using DocumentsContract#buildRootUri(String, String) with rootId directly doesn't work
val rootUri = DocumentsContract.buildRootsUri(authority)
val projection = arrayOf(COLUMN_AVAILABLE_BYTES)
// query directly for our rootId
val bytesAvailable = context.contentResolver.query(
rootUri, projection, "$COLUMN_ROOT_ID=?", arrayOf(rootId), null
)?.use { c ->
if (!c.moveToNext()) return@use null // no results
val bytes = c.getIntOrNull(c.getColumnIndex(COLUMN_AVAILABLE_BYTES))
if (bytes != null && bytes >= 0) return@use bytes.toLong()
else return@use null
}
// if we didn't get anything from SAF, try some known hacks
return if (bytesAvailable == null && authority == AUTHORITY_STORAGE) {
if (rootId == ROOT_ID_DEVICE) {
StatFs(Environment.getDataDirectory().absolutePath).availableBytes
} else if (storage.safStorage.isUsb) {
val documentId = storage.safStorage.uri.lastPathSegment ?: return null
StatFs("/mnt/media_rw/${documentId.trimEnd(':')}").availableBytes
} else null
} else bytesAvailable
return delegate.getFreeSpace()
}
@Throws(IOException::class)
override suspend fun startNewRestoreSet(token: Long) {
// reset current storage
storage.reset(token)
// no-op
}
@Throws(IOException::class)
override suspend fun initializeDevice() {
// reset storage without new token, so folders get recreated
// otherwise stale DocumentFiles will hang around
storage.reset(null)
// no-op
}
@Throws(IOException::class)
override suspend fun getOutputStream(token: Long, name: String): OutputStream {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.createOrGetFile(context, name)
return storage.getOutputStream(file)
val handle = when (name) {
FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
else -> LegacyAppBackupFile.Blob(token, name)
}
return delegate.save(handle).outputStream()
}
@Throws(IOException::class)
override suspend fun getInputStream(token: Long, name: String): InputStream {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.findFileBlocking(context, name) ?: throw FileNotFoundException()
return storage.getInputStream(file)
val handle = when (name) {
FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
else -> LegacyAppBackupFile.Blob(token, name)
}
return delegate.load(handle).inputStream()
}
@Throws(IOException::class)
override suspend fun removeData(token: Long, name: String) {
val setDir = storage.getSetDir(token) ?: throw IOException()
val file = setDir.findFileBlocking(context, name) ?: return
if (!file.delete()) throw IOException("Failed to delete $name")
val handle = when (name) {
FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
else -> LegacyAppBackupFile.Blob(token, name)
}
delegate.remove(handle)
}
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
val rootDir = storage.rootBackupDir ?: return null
val backupSets = getBackups(context, rootDir)
val iterator = backupSets.iterator()
return generateSequence {
if (!iterator.hasNext()) return@generateSequence null // end sequence
val backupSet = iterator.next()
EncryptedMetadata(backupSet.token) {
storage.getInputStream(backupSet.metadataFile)
return try {
// get all restore set tokens in root folder that have a metadata file
val tokens = ArrayList<Long>()
delegate.list(null, LegacyAppBackupFile.Metadata::class) { fileInfo ->
val handle = fileInfo.fileHandle as LegacyAppBackupFile.Metadata
tokens.add(handle.token)
}
}
}
override val providerPackageName: String? by lazy {
val authority = storage.getAuthority() ?: return@lazy null
val providerInfo = packageManager.resolveContentProvider(authority, 0) ?: return@lazy null
providerInfo.packageName
}
}
class BackupSet(val token: Long, val metadataFile: DocumentFile)
internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>()
val files = try {
// block until the DocumentsProvider has results
rootDir.listFilesBlocking(context)
} catch (e: IOException) {
Log.e(TAG, "Error loading backups from storage", e)
return backupSets
}
for (set in files) {
// retrieve name only once as this causes a DB query
val name = set.name
// get current token from set or continue to next file/set
val token = set.getTokenOrNull(name) ?: continue
// block until children of set are available
val metadata = try {
set.findFileBlocking(context, FILE_BACKUP_METADATA)
} catch (e: IOException) {
Log.e(TAG, "Error reading metadata file in backup set folder: $name", e)
val tokenIterator = tokens.iterator()
return generateSequence {
if (!tokenIterator.hasNext()) return@generateSequence null // end sequence
val token = tokenIterator.next()
EncryptedMetadata(token) {
getInputStream(token, FILE_BACKUP_METADATA)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting available backups: ", e)
null
}
if (metadata == null) {
Log.w(TAG, "Missing metadata file in backup set folder: $name")
} else {
backupSets.add(BackupSet(token, metadata))
}
}
return backupSets
}
private fun DocumentFile.getTokenOrNull(name: String?): Long? {
val looksLikeToken = name != null && tokenRegex.matches(name)
// check for isDirectory only if we already have a valid token (causes DB query)
if (!looksLikeToken || !isDirectory) {
// only log unexpected output
if (name != null && isUnexpectedFile(name)) {
Log.w(TAG, "Found invalid backup set folder: $name")
}
return null
}
return try {
name?.toLong()
} catch (e: NumberFormatException) {
throw AssertionError(e)
}
}
suspend fun removeAll() = delegate.removeAll()
override val providerPackageName: String? get() = delegate.providerPackageName
private fun isUnexpectedFile(name: String): Boolean {
return name != FILE_NO_MEDIA &&
!chunkFolderRegex.matches(name) &&
!name.endsWith(SNAPSHOT_EXT)
}

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@ -60,11 +61,7 @@ internal class DocumentsStorage(
if (field == null) {
val parent = safStorage.getDocumentFile(context)
field = try {
parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
// create .nomedia file to prevent Android's MediaScanner
// from trying to index the backup
createOrGetFile(context, FILE_NO_MEDIA)
}
parent.createOrGetDirectory(context, DIRECTORY_ROOT)
} catch (e: IOException) {
Log.e(TAG, "Error creating root backup dir.", e)
null
@ -73,41 +70,8 @@ internal class DocumentsStorage(
field
}
private var currentToken: Long? = null
get() {
if (field == null) field = settingsManager.getToken()
return field
}
var currentSetDir: DocumentFile? = null
get() = runBlocking {
if (field == null) {
if (currentToken == 0L) return@runBlocking null
field = try {
rootBackupDir?.createOrGetDirectory(context, currentToken.toString())
} catch (e: IOException) {
Log.e(TAG, "Error creating current restore set dir.", e)
null
}
}
field
}
private set
/**
* Resets this storage abstraction, forcing it to re-fetch cached values on next access.
*/
fun reset(newToken: Long?) {
currentToken = newToken
rootBackupDir = null
currentSetDir = null
}
fun getAuthority(): String? = safStorage.uri.authority
@Throws(IOException::class)
suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
if (token == currentToken) return currentSetDir
suspend fun getSetDir(token: Long): DocumentFile? {
return rootBackupDir?.findFileBlocking(context, token.toString())
}
@ -147,33 +111,6 @@ internal class DocumentsStorage(
}
/**
* Checks if a file exists and if not, creates it.
*
* If we were trying to create it right away, some providers create "filename (1)".
*/
@Throws(IOException::class)
internal suspend fun DocumentFile.createOrGetFile(
context: Context,
name: String,
mimeType: String = MIME_TYPE,
): DocumentFile {
return try {
findFileBlocking(context, name) ?: createFile(mimeType, name)?.apply {
if (this.name != name) {
throw IOException("File named ${this.name}, but should be $name")
}
} ?: throw IOException("could not find nor create")
} catch (e: Exception) {
// SAF can throw all sorts of exceptions, so wrap it in IOException.
// E.g. IllegalArgumentException can be thrown by FileSystemProvider#isChildDocument()
// when flash drive is not plugged-in:
// http://aosp.opersys.com/xref/android-11.0.0_r8/xref/frameworks/base/core/java/com/android/internal/content/FileSystemProvider.java#135
if (e is IOException) throw e
else throw IOException(e)
}
}
/**
* Checks if a directory already exists and if not, creates it.
*/
@ -186,11 +123,6 @@ suspend fun DocumentFile.createOrGetDirectory(context: Context, name: String): D
} ?: throw IOException()
}
@Throws(IOException::class)
suspend fun DocumentFile.deleteContents(context: Context) {
for (file in listFilesBlocking(context)) file.delete()
}
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
if (name != packageInfo.packageName) {
throw AssertionError("Expected ${packageInfo.packageName}, but got $name")
@ -224,26 +156,6 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile>
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.
*

View file

@ -7,29 +7,23 @@ package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.net.Uri
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.storage.SeedvaultSafStoragePlugin
class SafFactory(
private val context: Context,
private val keyManager: KeyManager,
private val settingsManager: SettingsManager,
) {
internal fun createAppStoragePlugin(
safStorage: SafStorage,
documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage),
): StoragePlugin<Uri> {
return DocumentsProviderStoragePlugin(context, documentsStorage)
return DocumentsProviderStoragePlugin(context, safStorage)
}
internal fun createFilesStoragePlugin(
safStorage: SafStorage,
documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage),
): org.calyxos.backup.storage.api.StoragePlugin {
return SeedvaultSafStoragePlugin(context, documentsStorage, keyManager)
return SeedvaultSafStoragePlugin(context, safStorage)
}
}

View file

@ -50,8 +50,7 @@ internal class SafHandler(
@WorkerThread
@Throws(IOException::class)
suspend fun hasAppBackup(safStorage: SafStorage): Boolean {
val storage = DocumentsStorage(context, settingsManager, safStorage)
val appPlugin = safFactory.createAppStoragePlugin(safStorage, storage)
val appPlugin = safFactory.createAppStoragePlugin(safStorage)
val backups = appPlugin.getAvailableBackups()
return backups != null && backups.iterator().hasNext()
}
@ -85,11 +84,10 @@ internal class SafHandler(
}
fun setPlugin(safStorage: SafStorage) {
val storage = DocumentsStorage(context, settingsManager, safStorage)
storagePluginManager.changePlugins(
storageProperties = safStorage,
appPlugin = safFactory.createAppStoragePlugin(safStorage, storage),
filesPlugin = safFactory.createFilesStoragePlugin(safStorage, storage),
appPlugin = safFactory.createAppStoragePlugin(safStorage),
filesPlugin = safFactory.createFilesStoragePlugin(safStorage),
)
}
}

View file

@ -6,21 +6,23 @@
package com.stevesoltys.seedvault.storage
import android.content.Context
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.plugins.saf.SafStorage
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT
import org.calyxos.seedvault.core.backends.saf.SafBackend
import org.calyxos.seedvault.core.backends.saf.SafConfig
internal class SeedvaultSafStoragePlugin(
private val appContext: Context,
private val storage: DocumentsStorage,
private val keyManager: KeyManager,
appContext: Context,
safStorage: SafStorage,
root: String = DIRECTORY_ROOT,
) : SafStoragePlugin(appContext) {
/**
* Attention: This context might be from a different user. Use with care.
*/
override val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
override val root: DocumentFile get() = storage.rootBackupDir ?: error("No storage set")
private val safConfig = SafConfig(
config = safStorage.config,
name = safStorage.name,
isUsb = safStorage.isUsb,
requiresNetwork = safStorage.requiresNetwork,
rootId = safStorage.rootId,
)
override val delegate: SafBackend = SafBackend(appContext, safConfig, root)
}

View file

@ -14,6 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.TestApp
import io.mockk.every
import io.mockk.mockk
import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue

View file

@ -1,59 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.saf
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.transport.backup.BackupTest
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
internal class StoragePluginTest : BackupTest() {
private val storage = mockk<DocumentsStorage>()
private val plugin = DocumentsProviderStoragePlugin(context, storage)
private val setDir: DocumentFile = mockk()
private val backupFile: 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
every { storage getProperty "safStorage" } returns null // just to check if isUsb
coEvery { storage.getSetDir(token) } returns setDir
// delete contents of current set dir
coEvery { setDir.listFilesBlocking(context) } returns listOf(backupFile)
every { backupFile.delete() } returns true
// reset storage
every { storage.reset(null) } just Runs
// create new set dir
every { storage getProperty "currentSetDir" } returns setDir
plugin.initializeDevice()
}
}

View file

@ -41,6 +41,7 @@ dependencies {
implementation(libs.bundles.kotlin)
implementation(libs.bundles.coroutines)
implementation(libs.androidx.documentfile)
implementation(libs.androidx.core.ktx)
// implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("okio-jvm-3.7.0.jar"))
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
implementation("io.github.oshai:kotlin-logging-jvm:6.0.3")

View file

@ -6,7 +6,13 @@
package org.calyxos.seedvault.core.backends.saf
import android.content.Context
import android.os.Environment
import android.os.StatFs
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
import android.provider.DocumentsContract.Root.COLUMN_ROOT_ID
import android.provider.DocumentsContract.renameDocument
import androidx.core.database.getIntOrNull
import androidx.documentfile.provider.DocumentFile
import io.github.oshai.kotlinlogging.KotlinLogging
import okio.BufferedSink
@ -31,6 +37,9 @@ import org.calyxos.seedvault.core.getBackendContext
import java.io.IOException
import kotlin.reflect.KClass
internal const val AUTHORITY_STORAGE = "com.android.externalstorage.documents"
internal const val ROOT_ID_DEVICE = "primary"
public class SafBackend(
private val appContext: Context,
private val safConfig: SafConfig,
@ -46,11 +55,33 @@ public class SafBackend(
private val cache = DocumentFileCache(context, safConfig.getDocumentFile(context), root)
override suspend fun test(): Boolean {
TODO("Not yet implemented")
return cache.getRootFile().isDirectory
}
override suspend fun getFreeSpace(): Long? {
TODO("Not yet implemented")
val rootId = safConfig.rootId ?: return null
val authority = safConfig.uri.authority
// using DocumentsContract#buildRootUri(String, String) with rootId directly doesn't work
val rootUri = DocumentsContract.buildRootsUri(authority)
val projection = arrayOf(COLUMN_AVAILABLE_BYTES)
// query directly for our rootId
val bytesAvailable = context.contentResolver.query(
rootUri, projection, "$COLUMN_ROOT_ID=?", arrayOf(rootId), null
)?.use { c ->
if (!c.moveToNext()) return@use null // no results
val bytes = c.getIntOrNull(c.getColumnIndex(COLUMN_AVAILABLE_BYTES))
if (bytes != null && bytes >= 0) return@use bytes.toLong()
else return@use null
}
// if we didn't get anything from SAF, try some known hacks
return if (bytesAvailable == null && authority == AUTHORITY_STORAGE) {
if (rootId == ROOT_ID_DEVICE) {
StatFs(Environment.getDataDirectory().absolutePath).availableBytes
} else if (safConfig.isUsb) {
val documentId = safConfig.uri.lastPathSegment ?: return null
StatFs("/mnt/media_rw/${documentId.trimEnd(':')}").availableBytes
} else null
} else bytesAvailable
}
override suspend fun save(handle: FileHandle): BufferedSink {
@ -156,6 +187,11 @@ public class SafBackend(
}
}
override val providerPackageName: String? get() = TODO("Not yet implemented")
override val providerPackageName: String? by lazy {
val authority = safConfig.uri.authority ?: return@lazy null
val providerInfo = context.packageManager.resolveContentProvider(authority, 0)
?: return@lazy null
providerInfo.packageName
}
}

View file

@ -7,22 +7,26 @@ package de.grobox.storagebackuptester.plugin
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
import org.calyxos.seedvault.core.backends.saf.SafBackend
import org.calyxos.seedvault.core.backends.saf.SafConfig
import java.io.IOException
import java.io.OutputStream
class TestSafStoragePlugin(
appContext: Context,
private val appContext: Context,
private val getLocationUri: () -> Uri?,
) : SafStoragePlugin(appContext) {
override val context = appContext
override val root: DocumentFile?
get() {
val uri = getLocationUri() ?: return null
return DocumentFile.fromTreeUri(context, uri) ?: error("No doc file from tree Uri")
}
private val safConfig
get() = SafConfig(
config = getLocationUri() ?: error("no uri"),
name = "foo",
isUsb = false,
requiresNetwork = false,
rootId = "bar",
)
override val delegate: SafBackend get() = SafBackend(appContext, safConfig)
private val nullStream = object : OutputStream() {
override fun write(b: Int) {
@ -37,7 +41,6 @@ class TestSafStoragePlugin(
}
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
if (root == null) return nullStream
return super.getBackupSnapshotOutputStream(timestamp)
}

View file

@ -94,6 +94,9 @@ dependencies {
implementation(libs.androidx.room.runtime)
implementation(libs.google.protobuf.javalite)
implementation(libs.google.tink.android)
// TODO include via gradle and AOSP
// https://android.googlesource.com/platform/external/okio/+/refs/tags/android-14.0.0_r53/CHANGELOG.md
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("okio-jvm-3.7.0.jar"))
ksp(group = "androidx.room", name = "room-compiler", version = libs.versions.room.get())
lintChecks(libs.thirdegg.lint.rules)

View file

@ -1,194 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
@file:Suppress("BlockingMethodInNonBlockingContext")
package org.calyxos.backup.storage.plugin.saf
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
import android.provider.DocumentsContract.EXTRA_LOADING
import android.provider.DocumentsContract.buildChildDocumentsUriUsingTree
import android.provider.DocumentsContract.buildDocumentUriUsingTree
import android.provider.DocumentsContract.getDocumentId
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import org.calyxos.backup.storage.openInputStream
import org.calyxos.backup.storage.openOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import kotlin.coroutines.resume
public object DocumentFileExt {
private const val TAG = "DocumentFileExt"
@Throws(IOException::class)
public fun DocumentFile.getInputStream(contentResolver: ContentResolver): InputStream {
return uri.openInputStream(contentResolver)
}
@Throws(IOException::class)
public fun DocumentFile.getOutputStream(contentResolver: ContentResolver): OutputStream {
return uri.openOutputStream(contentResolver)
}
@Throws(IOException::class)
public fun DocumentFile.createDirectoryOrThrow(name: String): DocumentFile {
val directory = createDirectory(name)
?: throw IOException("Unable to create directory: $name")
if (directory.name != name) {
directory.delete()
throw IOException("Wanted to directory $name, but got ${directory.name}")
}
return directory
}
@Throws(IOException::class)
public fun DocumentFile.createFileOrThrow(name: String, mimeType: String): DocumentFile {
val file = createFile(mimeType, name) ?: throw IOException("Unable to create file: $name")
if (file.name != name) {
file.delete()
if (file.name == null) { // this happens when file existed already
// try to find the original file we were looking for
val foundFile = findFile(name)
if (foundFile?.name == name) return foundFile
}
throw IOException("Wanted to create $name, but got ${file.name}")
}
return file
}
/**
* Works like [DocumentFile.listFiles] except
* that it waits until the DocumentProvider has a result.
* This prevents getting an empty list even though there are children to be listed.
*/
@Throws(IOException::class)
public suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile> {
val resolver = context.contentResolver
val childrenUri = buildChildDocumentsUriUsingTree(uri, getDocumentId(uri))
val projection = arrayOf(COLUMN_DOCUMENT_ID)
val result = ArrayList<DocumentFile>()
try {
getLoadedCursor {
resolver.query(childrenUri, projection, null, null, null)
}
} catch (e: TimeoutCancellationException) {
throw IOException(e)
}.use { cursor ->
while (cursor.moveToNext()) {
val documentId = cursor.getString(0)
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
@SuppressLint("CheckedExceptions")
public 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.
*
* Most documents providers including Nextcloud are listing the full directory content
* when querying for a specific file in a directory,
* so there is no point in trying to optimize the query by not listing all children.
*/
public suspend fun DocumentFile.findFileBlocking(
context: Context,
displayName: String,
): DocumentFile? {
val files = try {
listFilesBlocking(context)
} catch (e: IOException) {
Log.e(TAG, "Error finding file blocking", e)
return null
}
for (doc in files) {
if (displayName == doc.name) return doc
}
return null
}
/**
* Returns a cursor for the given query while ensuring that the cursor was loaded.
*
* When the SAF backend is a cloud storage provider (e.g. Nextcloud),
* it can happen that the query returns an outdated (e.g. empty) cursor
* which will only be updated in response to this query.
*
* See: https://commonsware.com/blog/2019/12/14/scoped-storage-stories-listfiles-woe.html
*
* This method uses a [suspendCancellableCoroutine] to wait for the result of a [ContentObserver]
* registered on the cursor in case the cursor is still loading ([EXTRA_LOADING]).
* If the cursor is not loading, it will be returned right away.
*
* @param timeout an optional time-out in milliseconds
* @throws TimeoutCancellationException if there was no result before the time-out
* @throws IOException if the query returns null
*/
@VisibleForTesting
@Throws(IOException::class, TimeoutCancellationException::class)
public suspend fun getLoadedCursor(timeout: Long = 15_000, query: () -> Cursor?): Cursor =
withTimeout(timeout) {
suspendCancellableCoroutine { cont ->
val cursor = query() ?: throw IOException()
cont.invokeOnCancellation { cursor.close() }
val loading = cursor.extras.getBoolean(EXTRA_LOADING, false)
if (loading) {
Log.d(TAG, "Wait for children to get loaded...")
cursor.registerContentObserver(object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
Log.d(TAG, "Children loaded. Continue...")
cursor.close()
val newCursor = query()
if (newCursor == null) {
cont.cancel(IOException("query returned no results"))
} else cont.resume(newCursor)
}
})
} else {
// not loading, return cursor right away
cont.resume(cursor)
}
}
}
}

View file

@ -1,38 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.backup.storage.plugin.saf
import androidx.documentfile.provider.DocumentFile
import org.calyxos.backup.storage.api.StoredSnapshot
import org.calyxos.backup.storage.plugin.PluginConstants.CHUNK_FOLDER_COUNT
/**
* Accessing files and attributes via SAF is costly.
* This class caches them to speed up SAF related operations.
*/
internal class SafCache {
/**
* The folder for the current user ID (here "${ANDROID_ID}.sv").
*/
var currentFolder: DocumentFile? = null
/**
* Folders containing chunks for backups of the current user ID.
*/
val backupChunkFolders = HashMap<String, DocumentFile>(CHUNK_FOLDER_COUNT)
/**
* Folders containing chunks for restore of a chosen [StoredSnapshot].
*/
val restoreChunkFolders = HashMap<String, DocumentFile>(CHUNK_FOLDER_COUNT)
/**
* Files for each [StoredSnapshot].
*/
val snapshotFiles = HashMap<StoredSnapshot, DocumentFile>()
}

View file

@ -9,71 +9,35 @@ import android.annotation.SuppressLint
import android.content.Context
import android.provider.Settings
import android.provider.Settings.Secure.ANDROID_ID
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import org.calyxos.backup.storage.api.StoragePlugin
import org.calyxos.backup.storage.api.StoredSnapshot
import org.calyxos.backup.storage.measure
import org.calyxos.backup.storage.plugin.PluginConstants.CHUNK_FOLDER_COUNT
import org.calyxos.backup.storage.plugin.PluginConstants.MIME_TYPE
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
import org.calyxos.backup.storage.plugin.PluginConstants.chunkFolderRegex
import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex
import org.calyxos.backup.storage.plugin.PluginConstants.folderRegex
import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex
import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.createDirectoryOrThrow
import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.createFileOrThrow
import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.findFileBlocking
import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.getInputStream
import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.getOutputStream
import org.calyxos.backup.storage.plugin.saf.DocumentFileExt.listFilesBlocking
import org.calyxos.seedvault.core.backends.FileBackupFileType
import org.calyxos.seedvault.core.backends.TopLevelFolder
import org.calyxos.seedvault.core.backends.saf.SafBackend
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import kotlin.time.ExperimentalTime
private const val TAG = "SafStoragePlugin"
/**
* @param appContext application context provided by the storage module
*/
@Suppress("BlockingMethodInNonBlockingContext")
public abstract class SafStoragePlugin(
private val appContext: Context,
) : StoragePlugin {
/**
* Attention: This context could be unexpected. E.g. the system user's application context,
* in the case of USB storage, if INTERACT_ACROSS_USERS_FULL permission is granted.
* Use [appContext], if you need the context of the current app and user
* and [context] for all file access.
*/
protected abstract val context: Context
protected abstract val root: DocumentFile?
private val cache = SafCache()
protected abstract val delegate: SafBackend
private val folder: DocumentFile?
get() {
val root = this.root ?: return null
if (cache.currentFolder != null) return cache.currentFolder
@SuppressLint("HardwareIds")
// This is unique to each combination of app-signing key, user, and device
// so we don't leak anything by not hashing this and can use it as is.
// Note: Use [appContext] here to not get the wrong ID for a different user.
val androidId = Settings.Secure.getString(appContext.contentResolver, ANDROID_ID)
// the folder name is our user ID
val folderName = "$androidId.sv"
cache.currentFolder = try {
root.findFile(folderName) ?: root.createDirectoryOrThrow(folderName)
} catch (e: IOException) {
Log.e(TAG, "Error creating storage folder $folderName")
null
}
return cache.currentFolder
}
private fun timestampToSnapshot(timestamp: Long): String {
return "$timestamp$SNAPSHOT_EXT"
private val androidId: String by lazy {
@SuppressLint("HardwareIds")
// This is unique to each combination of app-signing key, user, and device
// so we don't leak anything by not hashing this and can use it as is.
// Note: Use [appContext] here to not get the wrong ID for a different user.
val androidId = Settings.Secure.getString(appContext.contentResolver, ANDROID_ID)
androidId
}
private val topLevelFolder: TopLevelFolder by lazy {
// the folder name is our user ID
val folderName = "$androidId.sv"
TopLevelFolder(folderName)
}
override suspend fun init() {
@ -82,98 +46,23 @@ public abstract class SafStoragePlugin(
@Throws(IOException::class)
override suspend fun getAvailableChunkIds(): List<String> {
val folder = folder ?: return emptyList()
val chunkIds = ArrayList<String>()
populateChunkFolders(folder, cache.backupChunkFolders) { file, name ->
if (chunkFolderRegex.matches(name)) {
chunkIds.addAll(getChunksFromFolder(file))
}
delegate.list(topLevelFolder, FileBackupFileType.Blob::class) { fileInfo ->
chunkIds.add(fileInfo.fileHandle.name)
}
Log.i(TAG, "Got ${chunkIds.size} available chunks")
return chunkIds
}
/**
* Goes through all files in the given [folder] and performs the optional [fileOp] on them.
* Afterwards, it creates missing chunk folders, as needed.
* Chunk folders will get cached in the given [chunkFolders] for faster access.
*/
@Throws(IOException::class)
@OptIn(ExperimentalTime::class)
private suspend fun populateChunkFolders(
folder: DocumentFile,
chunkFolders: HashMap<String, DocumentFile>,
fileOp: ((DocumentFile, String) -> Unit)? = null,
) {
val expectedChunkFolders = (0x00..0xff).map {
Integer.toHexString(it).padStart(2, '0')
}.toHashSet()
val duration = measure {
for (file in folder.listFilesBlocking(context)) {
val name = file.name ?: continue
if (chunkFolderRegex.matches(name)) {
chunkFolders[name] = file
expectedChunkFolders.remove(name)
}
fileOp?.invoke(file, name)
}
}
Log.i(TAG, "Retrieving chunk folders took $duration")
createMissingChunkFolders(folder, chunkFolders, expectedChunkFolders)
}
@Throws(IOException::class)
private fun getChunksFromFolder(chunkFolder: DocumentFile): List<String> {
val chunkFiles = try {
chunkFolder.listFiles()
} catch (e: UnsupportedOperationException) {
// can happen if this wasn't a directory after all
throw IOException(e)
}
return chunkFiles.mapNotNull { chunkFile ->
val name = chunkFile.name ?: return@mapNotNull null
if (chunkRegex.matches(name)) name else null
}
}
@Throws(IOException::class)
@OptIn(ExperimentalTime::class)
private fun createMissingChunkFolders(
root: DocumentFile,
chunkFolders: HashMap<String, DocumentFile>,
expectedChunkFolders: Set<String>,
) {
val s = expectedChunkFolders.size
val duration = measure {
for ((i, chunkFolderName) in expectedChunkFolders.withIndex()) {
val file = root.createDirectoryOrThrow(chunkFolderName)
chunkFolders[chunkFolderName] = file
Log.d(TAG, "Created missing folder $chunkFolderName (${i + 1}/$s)")
}
if (chunkFolders.size != 256) {
throw IOException("Only have ${chunkFolders.size} chunk folders.")
}
}
if (s > 0) Log.i(TAG, "Creating $s missing chunk folders took $duration")
}
@Throws(IOException::class)
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
val chunkFolderName = chunkId.substring(0, 2)
val chunkFolder =
cache.backupChunkFolders[chunkFolderName] ?: error("No folder for chunk $chunkId")
// TODO should we check if it exists first?
val chunkFile = chunkFolder.createFileOrThrow(chunkId, MIME_TYPE)
return chunkFile.getOutputStream(context.contentResolver)
val fileHandle = FileBackupFileType.Blob(androidId, chunkId)
return delegate.save(fileHandle).outputStream()
}
@Throws(IOException::class)
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
val folder = folder ?: throw IOException()
val name = timestampToSnapshot(timestamp)
// TODO should we check if it exists first?
val snapshotFile = folder.createFileOrThrow(name, MIME_TYPE)
return snapshotFile.getOutputStream(context.contentResolver)
val fileHandle = FileBackupFileType.Snapshot(androidId, timestamp)
return delegate.save(fileHandle).outputStream()
}
/************************* Restore *******************************/
@ -181,34 +70,21 @@ public abstract class SafStoragePlugin(
@Throws(IOException::class)
override suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot> {
val snapshots = ArrayList<StoredSnapshot>()
root?.listFilesBlocking(context)?.forEach { folder ->
val folderName = folder.name ?: ""
if (!folderRegex.matches(folderName)) return@forEach
Log.i(TAG, "Checking $folderName for snapshots...")
for (file in folder.listFilesBlocking(context)) {
val name = file.name ?: continue
val match = snapshotRegex.matchEntire(name)
if (match != null) {
val timestamp = match.groupValues[1].toLong()
val storedSnapshot = StoredSnapshot(folderName, timestamp)
snapshots.add(storedSnapshot)
cache.snapshotFiles[storedSnapshot] = file
}
}
delegate.list(null, FileBackupFileType.Snapshot::class) { fileInfo ->
val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot
val folderName = handle.topLevelFolder.name
val timestamp = handle.time
val storedSnapshot = StoredSnapshot(folderName, timestamp)
snapshots.add(storedSnapshot)
}
Log.i(TAG, "Got ${snapshots.size} snapshots while populating chunk folders")
return snapshots
}
@Throws(IOException::class)
override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream {
val timestamp = storedSnapshot.timestamp
val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) {
getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp))
} ?: throw IOException("Could not get file for snapshot $timestamp")
return snapshotFile.getInputStream(context.contentResolver)
val androidId = storedSnapshot.androidId
val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp)
return delegate.load(handle).inputStream()
}
@Throws(IOException::class)
@ -216,75 +92,38 @@ public abstract class SafStoragePlugin(
snapshot: StoredSnapshot,
chunkId: String,
): InputStream {
if (cache.restoreChunkFolders.size < CHUNK_FOLDER_COUNT) {
populateChunkFolders(getFolder(snapshot), cache.restoreChunkFolders)
}
val chunkFolderName = chunkId.substring(0, 2)
val chunkFolder = cache.restoreChunkFolders[chunkFolderName]
?: throw IOException("No folder for chunk $chunkId")
val chunkFile = chunkFolder.findFileBlocking(context, chunkId)
?: throw IOException("No chunk $chunkId")
return chunkFile.getInputStream(context.contentResolver)
}
@Throws(IOException::class)
private suspend fun getFolder(storedSnapshot: StoredSnapshot): DocumentFile {
// not cached, because used in several places only once and
// [getBackupSnapshotInputStream] uses snapshot files cache and
// [getChunkInputStream] uses restore chunk folders cache
return root?.findFileBlocking(context, storedSnapshot.userId)
?: throw IOException("Could not find snapshot $storedSnapshot")
val handle = FileBackupFileType.Blob(snapshot.androidId, chunkId)
return delegate.load(handle).inputStream()
}
/************************* Pruning *******************************/
@Throws(IOException::class)
override suspend fun getCurrentBackupSnapshots(): List<StoredSnapshot> {
val folder = folder ?: return emptyList()
val folderName = folder.name ?: error("Folder suddenly has no more name")
val snapshots = ArrayList<StoredSnapshot>()
populateChunkFolders(folder, cache.backupChunkFolders) { file, name ->
val match = snapshotRegex.matchEntire(name)
if (match != null) {
val timestamp = match.groupValues[1].toLong()
val storedSnapshot = StoredSnapshot(folderName, timestamp)
snapshots.add(storedSnapshot)
cache.snapshotFiles[storedSnapshot] = file
}
delegate.list(topLevelFolder, FileBackupFileType.Snapshot::class) { fileInfo ->
val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot
val folderName = handle.topLevelFolder.name
val timestamp = handle.time
val storedSnapshot = StoredSnapshot(folderName, timestamp)
snapshots.add(storedSnapshot)
}
Log.i(TAG, "Got ${snapshots.size} snapshots while populating chunk folders")
return snapshots
}
@Throws(IOException::class)
override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) {
val timestamp = storedSnapshot.timestamp
Log.d(TAG, "Deleting snapshot $timestamp")
val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) {
getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp))
} ?: throw IOException("Could not get file for snapshot $timestamp")
if (!snapshotFile.delete()) throw IOException("Could not delete snapshot $timestamp")
cache.snapshotFiles.remove(storedSnapshot)
val androidId = storedSnapshot.androidId
val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp)
delegate.remove(handle)
}
@Throws(IOException::class)
override suspend fun deleteChunks(chunkIds: List<String>) {
if (cache.backupChunkFolders.size < CHUNK_FOLDER_COUNT) {
val folder = folder ?: throw IOException("Could not get current folder in root")
populateChunkFolders(folder, cache.backupChunkFolders)
}
for (chunkId in chunkIds) {
Log.d(TAG, "Deleting chunk $chunkId")
val chunkFolderName = chunkId.substring(0, 2)
val chunkFolder = cache.backupChunkFolders[chunkFolderName]
?: throw IOException("No folder for chunk $chunkId")
val chunkFile = chunkFolder.findFileBlocking(context, chunkId)
if (chunkFile == null) {
Log.w(TAG, "Could not find $chunkId")
} else {
if (!chunkFile.delete()) throw IOException("Could not delete chunk $chunkId")
}
chunkIds.forEach { chunkId ->
val androidId = topLevelFolder.name.substringBefore(".sv")
val handle = FileBackupFileType.Blob(androidId, chunkId)
delegate.remove(handle)
}
}