Use new SafBackend in DocumentsProviderStoragePlugin
This commit is contained in:
parent
8c05ccc39d
commit
5bb599e528
18 changed files with 188 additions and 1000 deletions
|
|
@ -5,18 +5,15 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault
|
package com.stevesoltys.seedvault
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.test.core.content.pm.PackageInfoBuilder
|
import androidx.test.core.content.pm.PackageInfoBuilder
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
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.DocumentsProviderLegacyPlugin
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||||
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
|
||||||
import com.stevesoltys.seedvault.plugins.saf.deleteContents
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
|
@ -24,7 +21,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
|
|
@ -46,7 +42,7 @@ class PluginTest : KoinComponent {
|
||||||
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
|
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
|
||||||
)
|
)
|
||||||
|
|
||||||
private val storagePlugin: StoragePlugin<Uri> = DocumentsProviderStoragePlugin(context, storage)
|
private val storagePlugin = DocumentsProviderStoragePlugin(context, storage.safStorage)
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
|
private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
|
||||||
|
|
@ -60,13 +56,12 @@ class PluginTest : KoinComponent {
|
||||||
@Before
|
@Before
|
||||||
fun setup() = runBlocking {
|
fun setup() = runBlocking {
|
||||||
every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage()
|
every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage()
|
||||||
storage.rootBackupDir?.deleteContents(context)
|
storagePlugin.removeAll()
|
||||||
?: error("Select a storage location in the app first!")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() = runBlocking {
|
fun tearDown() = runBlocking {
|
||||||
storage.rootBackupDir?.deleteContents(context)
|
storagePlugin.removeAll()
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,9 +119,6 @@ class PluginTest : KoinComponent {
|
||||||
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
|
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
|
||||||
.writeAndClose(getRandomByteArray())
|
.writeAndClose(getRandomByteArray())
|
||||||
assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
|
assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||||
|
|
||||||
// ensure that the new backup dir exist
|
|
||||||
assertTrue(storage.currentSetDir!!.exists())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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.getStorageContext
|
||||||
import com.stevesoltys.seedvault.permitDiskReads
|
import com.stevesoltys.seedvault.permitDiskReads
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
|
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.saf.SafFactory
|
||||||
import com.stevesoltys.seedvault.plugins.webdav.WebDavFactory
|
import com.stevesoltys.seedvault.plugins.webdav.WebDavFactory
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
|
@ -51,9 +50,8 @@ class StoragePluginManager(
|
||||||
when (settingsManager.storagePluginType) {
|
when (settingsManager.storagePluginType) {
|
||||||
StoragePluginType.SAF -> {
|
StoragePluginType.SAF -> {
|
||||||
val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage saved")
|
val safStorage = settingsManager.getSafStorage() ?: error("No SAF storage saved")
|
||||||
val documentsStorage = DocumentsStorage(context, settingsManager, safStorage)
|
mAppPlugin = safFactory.createAppStoragePlugin(safStorage)
|
||||||
mAppPlugin = safFactory.createAppStoragePlugin(safStorage, documentsStorage)
|
mFilesPlugin = safFactory.createFilesStoragePlugin(safStorage)
|
||||||
mFilesPlugin = safFactory.createFilesStoragePlugin(safStorage, documentsStorage)
|
|
||||||
mStorageProperties = safStorage
|
mStorageProperties = safStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val storagePluginModuleSaf = module {
|
val storagePluginModuleSaf = module {
|
||||||
single { SafFactory(androidContext(), get(), get()) }
|
single { SafFactory(androidContext()) }
|
||||||
single { SafHandler(androidContext(), get(), get(), get()) }
|
single { SafHandler(androidContext(), get(), get(), get()) }
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,15 @@
|
||||||
package com.stevesoltys.seedvault.plugins.saf
|
package com.stevesoltys.seedvault.plugins.saf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
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 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.EncryptedMetadata
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
|
import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS
|
||||||
import com.stevesoltys.seedvault.plugins.tokenRegex
|
import org.calyxos.seedvault.core.backends.Constants.DIRECTORY_ROOT
|
||||||
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
|
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||||
import com.stevesoltys.seedvault.ui.storage.ROOT_ID_DEVICE
|
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
||||||
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
|
import org.calyxos.seedvault.core.backends.saf.SafConfig
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
@ -32,156 +22,92 @@ import java.io.OutputStream
|
||||||
private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
|
private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
|
||||||
|
|
||||||
internal class DocumentsProviderStoragePlugin(
|
internal class DocumentsProviderStoragePlugin(
|
||||||
private val appContext: Context,
|
appContext: Context,
|
||||||
private val storage: DocumentsStorage,
|
safStorage: SafStorage,
|
||||||
|
root: String = DIRECTORY_ROOT,
|
||||||
) : StoragePlugin<Uri> {
|
) : StoragePlugin<Uri> {
|
||||||
|
|
||||||
/**
|
private val safConfig = SafConfig(
|
||||||
* Attention: This context might be from a different user. Use with care.
|
config = safStorage.config,
|
||||||
*/
|
name = safStorage.name,
|
||||||
private val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
|
isUsb = safStorage.isUsb,
|
||||||
|
requiresNetwork = safStorage.requiresNetwork,
|
||||||
private val packageManager: PackageManager = appContext.packageManager
|
rootId = safStorage.rootId,
|
||||||
|
)
|
||||||
|
private val delegate: SafBackend = SafBackend(appContext, safConfig, root)
|
||||||
|
|
||||||
override suspend fun test(): Boolean {
|
override suspend fun test(): Boolean {
|
||||||
val dir = storage.rootBackupDir
|
return delegate.test()
|
||||||
return dir != null && dir.exists()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getFreeSpace(): Long? {
|
override suspend fun getFreeSpace(): Long? {
|
||||||
val rootId = storage.safStorage.rootId ?: return null
|
return delegate.getFreeSpace()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun startNewRestoreSet(token: Long) {
|
override suspend fun startNewRestoreSet(token: Long) {
|
||||||
// reset current storage
|
// no-op
|
||||||
storage.reset(token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun initializeDevice() {
|
override suspend fun initializeDevice() {
|
||||||
// reset storage without new token, so folders get recreated
|
// no-op
|
||||||
// otherwise stale DocumentFiles will hang around
|
|
||||||
storage.reset(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getOutputStream(token: Long, name: String): OutputStream {
|
override suspend fun getOutputStream(token: Long, name: String): OutputStream {
|
||||||
val setDir = storage.getSetDir(token) ?: throw IOException()
|
val handle = when (name) {
|
||||||
val file = setDir.createOrGetFile(context, name)
|
FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
|
||||||
return storage.getOutputStream(file)
|
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
|
||||||
|
else -> LegacyAppBackupFile.Blob(token, name)
|
||||||
|
}
|
||||||
|
return delegate.save(handle).outputStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getInputStream(token: Long, name: String): InputStream {
|
override suspend fun getInputStream(token: Long, name: String): InputStream {
|
||||||
val setDir = storage.getSetDir(token) ?: throw IOException()
|
val handle = when (name) {
|
||||||
val file = setDir.findFileBlocking(context, name) ?: throw FileNotFoundException()
|
FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
|
||||||
return storage.getInputStream(file)
|
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
|
||||||
|
else -> LegacyAppBackupFile.Blob(token, name)
|
||||||
|
}
|
||||||
|
return delegate.load(handle).inputStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun removeData(token: Long, name: String) {
|
override suspend fun removeData(token: Long, name: String) {
|
||||||
val setDir = storage.getSetDir(token) ?: throw IOException()
|
val handle = when (name) {
|
||||||
val file = setDir.findFileBlocking(context, name) ?: return
|
FILE_BACKUP_METADATA -> LegacyAppBackupFile.Metadata(token)
|
||||||
if (!file.delete()) throw IOException("Failed to delete $name")
|
FILE_BACKUP_ICONS -> LegacyAppBackupFile.IconsFile(token)
|
||||||
|
else -> LegacyAppBackupFile.Blob(token, name)
|
||||||
|
}
|
||||||
|
delegate.remove(handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
|
||||||
val rootDir = storage.rootBackupDir ?: return null
|
return try {
|
||||||
val backupSets = getBackups(context, rootDir)
|
// get all restore set tokens in root folder that have a metadata file
|
||||||
val iterator = backupSets.iterator()
|
val tokens = ArrayList<Long>()
|
||||||
|
delegate.list(null, LegacyAppBackupFile.Metadata::class) { fileInfo ->
|
||||||
|
val handle = fileInfo.fileHandle as LegacyAppBackupFile.Metadata
|
||||||
|
tokens.add(handle.token)
|
||||||
|
}
|
||||||
|
val tokenIterator = tokens.iterator()
|
||||||
return generateSequence {
|
return generateSequence {
|
||||||
if (!iterator.hasNext()) return@generateSequence null // end sequence
|
if (!tokenIterator.hasNext()) return@generateSequence null // end sequence
|
||||||
val backupSet = iterator.next()
|
val token = tokenIterator.next()
|
||||||
EncryptedMetadata(backupSet.token) {
|
EncryptedMetadata(token) {
|
||||||
storage.getInputStream(backupSet.metadataFile)
|
getInputStream(token, FILE_BACKUP_METADATA)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error getting available backups: ", e)
|
||||||
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)
|
|
||||||
null
|
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? {
|
suspend fun removeAll() = delegate.removeAll()
|
||||||
val looksLikeToken = name != null && tokenRegex.matches(name)
|
|
||||||
// check for isDirectory only if we already have a valid token (causes DB query)
|
override val providerPackageName: String? get() = delegate.providerPackageName
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.runBlocking
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
@ -60,11 +61,7 @@ internal class DocumentsStorage(
|
||||||
if (field == null) {
|
if (field == null) {
|
||||||
val parent = safStorage.getDocumentFile(context)
|
val parent = safStorage.getDocumentFile(context)
|
||||||
field = try {
|
field = try {
|
||||||
parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
|
parent.createOrGetDirectory(context, DIRECTORY_ROOT)
|
||||||
// create .nomedia file to prevent Android's MediaScanner
|
|
||||||
// from trying to index the backup
|
|
||||||
createOrGetFile(context, FILE_NO_MEDIA)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error creating root backup dir.", e)
|
Log.e(TAG, "Error creating root backup dir.", e)
|
||||||
null
|
null
|
||||||
|
|
@ -73,41 +70,8 @@ internal class DocumentsStorage(
|
||||||
field
|
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)
|
@Throws(IOException::class)
|
||||||
suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
|
suspend fun getSetDir(token: Long): DocumentFile? {
|
||||||
if (token == currentToken) return currentSetDir
|
|
||||||
return rootBackupDir?.findFileBlocking(context, token.toString())
|
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.
|
* 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()
|
} ?: throw IOException()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
suspend fun DocumentFile.deleteContents(context: Context) {
|
|
||||||
for (file in listFilesBlocking(context)) file.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
|
fun DocumentFile.assertRightFile(packageInfo: PackageInfo) {
|
||||||
if (name != packageInfo.packageName) {
|
if (name != packageInfo.packageName) {
|
||||||
throw AssertionError("Expected ${packageInfo.packageName}, but got $name")
|
throw AssertionError("Expected ${packageInfo.packageName}, but got $name")
|
||||||
|
|
@ -224,26 +156,6 @@ suspend fun DocumentFile.listFilesBlocking(context: Context): List<DocumentFile>
|
||||||
return result
|
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.
|
* 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.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
|
||||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
|
||||||
import com.stevesoltys.seedvault.storage.SeedvaultSafStoragePlugin
|
import com.stevesoltys.seedvault.storage.SeedvaultSafStoragePlugin
|
||||||
|
|
||||||
class SafFactory(
|
class SafFactory(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val keyManager: KeyManager,
|
|
||||||
private val settingsManager: SettingsManager,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
internal fun createAppStoragePlugin(
|
internal fun createAppStoragePlugin(
|
||||||
safStorage: SafStorage,
|
safStorage: SafStorage,
|
||||||
documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage),
|
|
||||||
): StoragePlugin<Uri> {
|
): StoragePlugin<Uri> {
|
||||||
return DocumentsProviderStoragePlugin(context, documentsStorage)
|
return DocumentsProviderStoragePlugin(context, safStorage)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun createFilesStoragePlugin(
|
internal fun createFilesStoragePlugin(
|
||||||
safStorage: SafStorage,
|
safStorage: SafStorage,
|
||||||
documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage),
|
|
||||||
): org.calyxos.backup.storage.api.StoragePlugin {
|
): org.calyxos.backup.storage.api.StoragePlugin {
|
||||||
return SeedvaultSafStoragePlugin(context, documentsStorage, keyManager)
|
return SeedvaultSafStoragePlugin(context, safStorage)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,7 @@ internal class SafHandler(
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
suspend fun hasAppBackup(safStorage: SafStorage): Boolean {
|
suspend fun hasAppBackup(safStorage: SafStorage): Boolean {
|
||||||
val storage = DocumentsStorage(context, settingsManager, safStorage)
|
val appPlugin = safFactory.createAppStoragePlugin(safStorage)
|
||||||
val appPlugin = safFactory.createAppStoragePlugin(safStorage, storage)
|
|
||||||
val backups = appPlugin.getAvailableBackups()
|
val backups = appPlugin.getAvailableBackups()
|
||||||
return backups != null && backups.iterator().hasNext()
|
return backups != null && backups.iterator().hasNext()
|
||||||
}
|
}
|
||||||
|
|
@ -85,11 +84,10 @@ internal class SafHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPlugin(safStorage: SafStorage) {
|
fun setPlugin(safStorage: SafStorage) {
|
||||||
val storage = DocumentsStorage(context, settingsManager, safStorage)
|
|
||||||
storagePluginManager.changePlugins(
|
storagePluginManager.changePlugins(
|
||||||
storageProperties = safStorage,
|
storageProperties = safStorage,
|
||||||
appPlugin = safFactory.createAppStoragePlugin(safStorage, storage),
|
appPlugin = safFactory.createAppStoragePlugin(safStorage),
|
||||||
filesPlugin = safFactory.createFilesStoragePlugin(safStorage, storage),
|
filesPlugin = safFactory.createFilesStoragePlugin(safStorage),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,23 @@
|
||||||
package com.stevesoltys.seedvault.storage
|
package com.stevesoltys.seedvault.storage
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
||||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
|
||||||
import com.stevesoltys.seedvault.getStorageContext
|
|
||||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
|
||||||
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
|
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(
|
internal class SeedvaultSafStoragePlugin(
|
||||||
private val appContext: Context,
|
appContext: Context,
|
||||||
private val storage: DocumentsStorage,
|
safStorage: SafStorage,
|
||||||
private val keyManager: KeyManager,
|
root: String = DIRECTORY_ROOT,
|
||||||
) : SafStoragePlugin(appContext) {
|
) : SafStoragePlugin(appContext) {
|
||||||
/**
|
private val safConfig = SafConfig(
|
||||||
* Attention: This context might be from a different user. Use with care.
|
config = safStorage.config,
|
||||||
*/
|
name = safStorage.name,
|
||||||
override val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
|
isUsb = safStorage.isUsb,
|
||||||
override val root: DocumentFile get() = storage.rootBackupDir ?: error("No storage set")
|
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 com.stevesoltys.seedvault.TestApp
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import org.calyxos.seedvault.core.backends.saf.getTreeDocumentFile
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
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.kotlin)
|
||||||
implementation(libs.bundles.coroutines)
|
implementation(libs.bundles.coroutines)
|
||||||
implementation(libs.androidx.documentfile)
|
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("okio-jvm-3.7.0.jar"))
|
||||||
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
|
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
|
||||||
implementation("io.github.oshai:kotlin-logging-jvm:6.0.3")
|
implementation("io.github.oshai:kotlin-logging-jvm:6.0.3")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@
|
||||||
package org.calyxos.seedvault.core.backends.saf
|
package org.calyxos.seedvault.core.backends.saf
|
||||||
|
|
||||||
import android.content.Context
|
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 android.provider.DocumentsContract.renameDocument
|
||||||
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import okio.BufferedSink
|
import okio.BufferedSink
|
||||||
|
|
@ -31,6 +37,9 @@ import org.calyxos.seedvault.core.getBackendContext
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
internal const val AUTHORITY_STORAGE = "com.android.externalstorage.documents"
|
||||||
|
internal const val ROOT_ID_DEVICE = "primary"
|
||||||
|
|
||||||
public class SafBackend(
|
public class SafBackend(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
private val safConfig: SafConfig,
|
private val safConfig: SafConfig,
|
||||||
|
|
@ -46,11 +55,33 @@ public class SafBackend(
|
||||||
private val cache = DocumentFileCache(context, safConfig.getDocumentFile(context), root)
|
private val cache = DocumentFileCache(context, safConfig.getDocumentFile(context), root)
|
||||||
|
|
||||||
override suspend fun test(): Boolean {
|
override suspend fun test(): Boolean {
|
||||||
TODO("Not yet implemented")
|
return cache.getRootFile().isDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getFreeSpace(): Long? {
|
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 {
|
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.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
|
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.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
class TestSafStoragePlugin(
|
class TestSafStoragePlugin(
|
||||||
appContext: Context,
|
private val appContext: Context,
|
||||||
private val getLocationUri: () -> Uri?,
|
private val getLocationUri: () -> Uri?,
|
||||||
) : SafStoragePlugin(appContext) {
|
) : SafStoragePlugin(appContext) {
|
||||||
|
|
||||||
override val context = appContext
|
private val safConfig
|
||||||
override val root: DocumentFile?
|
get() = SafConfig(
|
||||||
get() {
|
config = getLocationUri() ?: error("no uri"),
|
||||||
val uri = getLocationUri() ?: return null
|
name = "foo",
|
||||||
return DocumentFile.fromTreeUri(context, uri) ?: error("No doc file from tree Uri")
|
isUsb = false,
|
||||||
}
|
requiresNetwork = false,
|
||||||
|
rootId = "bar",
|
||||||
|
)
|
||||||
|
override val delegate: SafBackend get() = SafBackend(appContext, safConfig)
|
||||||
|
|
||||||
private val nullStream = object : OutputStream() {
|
private val nullStream = object : OutputStream() {
|
||||||
override fun write(b: Int) {
|
override fun write(b: Int) {
|
||||||
|
|
@ -37,7 +41,6 @@ class TestSafStoragePlugin(
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
||||||
if (root == null) return nullStream
|
|
||||||
return super.getBackupSnapshotOutputStream(timestamp)
|
return super.getBackupSnapshotOutputStream(timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,9 @@ dependencies {
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
implementation(libs.google.protobuf.javalite)
|
implementation(libs.google.protobuf.javalite)
|
||||||
implementation(libs.google.tink.android)
|
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())
|
ksp(group = "androidx.room", name = "room-compiler", version = libs.versions.room.get())
|
||||||
lintChecks(libs.thirdegg.lint.rules)
|
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.content.Context
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.provider.Settings.Secure.ANDROID_ID
|
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.StoragePlugin
|
||||||
import org.calyxos.backup.storage.api.StoredSnapshot
|
import org.calyxos.backup.storage.api.StoredSnapshot
|
||||||
import org.calyxos.backup.storage.measure
|
import org.calyxos.seedvault.core.backends.FileBackupFileType
|
||||||
import org.calyxos.backup.storage.plugin.PluginConstants.CHUNK_FOLDER_COUNT
|
import org.calyxos.seedvault.core.backends.TopLevelFolder
|
||||||
import org.calyxos.backup.storage.plugin.PluginConstants.MIME_TYPE
|
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
||||||
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 java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
|
|
||||||
private const val TAG = "SafStoragePlugin"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param appContext application context provided by the storage module
|
* @param appContext application context provided by the storage module
|
||||||
*/
|
*/
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
public abstract class SafStoragePlugin(
|
public abstract class SafStoragePlugin(
|
||||||
private val appContext: Context,
|
private val appContext: Context,
|
||||||
) : StoragePlugin {
|
) : StoragePlugin {
|
||||||
/**
|
protected abstract val delegate: SafBackend
|
||||||
* 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()
|
|
||||||
|
|
||||||
private val folder: DocumentFile?
|
|
||||||
get() {
|
|
||||||
val root = this.root ?: return null
|
|
||||||
if (cache.currentFolder != null) return cache.currentFolder
|
|
||||||
|
|
||||||
|
private val androidId: String by lazy {
|
||||||
@SuppressLint("HardwareIds")
|
@SuppressLint("HardwareIds")
|
||||||
// This is unique to each combination of app-signing key, user, and device
|
// 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.
|
// 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.
|
// Note: Use [appContext] here to not get the wrong ID for a different user.
|
||||||
val androidId = Settings.Secure.getString(appContext.contentResolver, ANDROID_ID)
|
val androidId = Settings.Secure.getString(appContext.contentResolver, ANDROID_ID)
|
||||||
|
androidId
|
||||||
|
}
|
||||||
|
private val topLevelFolder: TopLevelFolder by lazy {
|
||||||
// the folder name is our user ID
|
// the folder name is our user ID
|
||||||
val folderName = "$androidId.sv"
|
val folderName = "$androidId.sv"
|
||||||
cache.currentFolder = try {
|
TopLevelFolder(folderName)
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun init() {
|
override suspend fun init() {
|
||||||
|
|
@ -82,98 +46,23 @@ public abstract class SafStoragePlugin(
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getAvailableChunkIds(): List<String> {
|
override suspend fun getAvailableChunkIds(): List<String> {
|
||||||
val folder = folder ?: return emptyList()
|
|
||||||
val chunkIds = ArrayList<String>()
|
val chunkIds = ArrayList<String>()
|
||||||
populateChunkFolders(folder, cache.backupChunkFolders) { file, name ->
|
delegate.list(topLevelFolder, FileBackupFileType.Blob::class) { fileInfo ->
|
||||||
if (chunkFolderRegex.matches(name)) {
|
chunkIds.add(fileInfo.fileHandle.name)
|
||||||
chunkIds.addAll(getChunksFromFolder(file))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Log.i(TAG, "Got ${chunkIds.size} available chunks")
|
|
||||||
return chunkIds
|
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)
|
@Throws(IOException::class)
|
||||||
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
|
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
|
||||||
val chunkFolderName = chunkId.substring(0, 2)
|
val fileHandle = FileBackupFileType.Blob(androidId, chunkId)
|
||||||
val chunkFolder =
|
return delegate.save(fileHandle).outputStream()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
||||||
val folder = folder ?: throw IOException()
|
val fileHandle = FileBackupFileType.Snapshot(androidId, timestamp)
|
||||||
val name = timestampToSnapshot(timestamp)
|
return delegate.save(fileHandle).outputStream()
|
||||||
// TODO should we check if it exists first?
|
|
||||||
val snapshotFile = folder.createFileOrThrow(name, MIME_TYPE)
|
|
||||||
return snapshotFile.getOutputStream(context.contentResolver)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/************************* Restore *******************************/
|
/************************* Restore *******************************/
|
||||||
|
|
@ -181,34 +70,21 @@ public abstract class SafStoragePlugin(
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot> {
|
override suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot> {
|
||||||
val snapshots = ArrayList<StoredSnapshot>()
|
val snapshots = ArrayList<StoredSnapshot>()
|
||||||
|
delegate.list(null, FileBackupFileType.Snapshot::class) { fileInfo ->
|
||||||
root?.listFilesBlocking(context)?.forEach { folder ->
|
val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot
|
||||||
val folderName = folder.name ?: ""
|
val folderName = handle.topLevelFolder.name
|
||||||
if (!folderRegex.matches(folderName)) return@forEach
|
val timestamp = handle.time
|
||||||
|
|
||||||
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)
|
val storedSnapshot = StoredSnapshot(folderName, timestamp)
|
||||||
snapshots.add(storedSnapshot)
|
snapshots.add(storedSnapshot)
|
||||||
cache.snapshotFiles[storedSnapshot] = file
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.i(TAG, "Got ${snapshots.size} snapshots while populating chunk folders")
|
|
||||||
return snapshots
|
return snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream {
|
override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream {
|
||||||
val timestamp = storedSnapshot.timestamp
|
val androidId = storedSnapshot.androidId
|
||||||
val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) {
|
val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp)
|
||||||
getFolder(storedSnapshot).findFileBlocking(context, timestampToSnapshot(timestamp))
|
return delegate.load(handle).inputStream()
|
||||||
} ?: throw IOException("Could not get file for snapshot $timestamp")
|
|
||||||
return snapshotFile.getInputStream(context.contentResolver)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
|
|
@ -216,75 +92,38 @@ public abstract class SafStoragePlugin(
|
||||||
snapshot: StoredSnapshot,
|
snapshot: StoredSnapshot,
|
||||||
chunkId: String,
|
chunkId: String,
|
||||||
): InputStream {
|
): InputStream {
|
||||||
if (cache.restoreChunkFolders.size < CHUNK_FOLDER_COUNT) {
|
val handle = FileBackupFileType.Blob(snapshot.androidId, chunkId)
|
||||||
populateChunkFolders(getFolder(snapshot), cache.restoreChunkFolders)
|
return delegate.load(handle).inputStream()
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/************************* Pruning *******************************/
|
/************************* Pruning *******************************/
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun getCurrentBackupSnapshots(): List<StoredSnapshot> {
|
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>()
|
val snapshots = ArrayList<StoredSnapshot>()
|
||||||
|
delegate.list(topLevelFolder, FileBackupFileType.Snapshot::class) { fileInfo ->
|
||||||
populateChunkFolders(folder, cache.backupChunkFolders) { file, name ->
|
val handle = fileInfo.fileHandle as FileBackupFileType.Snapshot
|
||||||
val match = snapshotRegex.matchEntire(name)
|
val folderName = handle.topLevelFolder.name
|
||||||
if (match != null) {
|
val timestamp = handle.time
|
||||||
val timestamp = match.groupValues[1].toLong()
|
|
||||||
val storedSnapshot = StoredSnapshot(folderName, timestamp)
|
val storedSnapshot = StoredSnapshot(folderName, timestamp)
|
||||||
snapshots.add(storedSnapshot)
|
snapshots.add(storedSnapshot)
|
||||||
cache.snapshotFiles[storedSnapshot] = file
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Log.i(TAG, "Got ${snapshots.size} snapshots while populating chunk folders")
|
|
||||||
return snapshots
|
return snapshots
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) {
|
override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) {
|
||||||
val timestamp = storedSnapshot.timestamp
|
val androidId = storedSnapshot.androidId
|
||||||
Log.d(TAG, "Deleting snapshot $timestamp")
|
val handle = FileBackupFileType.Snapshot(androidId, storedSnapshot.timestamp)
|
||||||
val snapshotFile = cache.snapshotFiles.getOrElse(storedSnapshot) {
|
delegate.remove(handle)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override suspend fun deleteChunks(chunkIds: List<String>) {
|
override suspend fun deleteChunks(chunkIds: List<String>) {
|
||||||
if (cache.backupChunkFolders.size < CHUNK_FOLDER_COUNT) {
|
chunkIds.forEach { chunkId ->
|
||||||
val folder = folder ?: throw IOException("Could not get current folder in root")
|
val androidId = topLevelFolder.name.substringBefore(".sv")
|
||||||
populateChunkFolders(folder, cache.backupChunkFolders)
|
val handle = FileBackupFileType.Blob(androidId, chunkId)
|
||||||
}
|
delegate.remove(handle)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue