Use new SafBackend in DocumentsProviderStoragePlugin
This commit is contained in:
parent
8c05ccc39d
commit
5bb599e528
18 changed files with 188 additions and 1000 deletions
app/src
androidTest/java/com/stevesoltys/seedvault
main/java/com/stevesoltys/seedvault
test/java/com/stevesoltys/seedvault/plugins/saf
core
storage
demo/src/main/java/de/grobox/storagebackuptester/plugin
lib
build.gradle.kts
src/main/java/org/calyxos/backup/storage/plugin/saf
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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>()
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue