Merge pull request #647 from grote/webdav

Implement a native WebDAV plugin
This commit is contained in:
Torsten Grote 2024-05-06 13:41:45 -03:00 committed by GitHub
commit d0cf168198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
120 changed files with 2658 additions and 638 deletions

View file

@ -35,6 +35,10 @@ jobs:
cache: 'gradle' cache: 'gradle'
- name: Build - name: Build
env:
NEXTCLOUD_URL: ${{ vars.NEXTCLOUD_URL }}
NEXTCLOUD_USER: ${{ secrets.NEXTCLOUD_USER }}
NEXTCLOUD_PASS: ${{ secrets.NEXTCLOUD_PASS }}
run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
- name: Upload APKs - name: Upload APKs

View file

@ -44,6 +44,10 @@ android_app {
"seedvault-lib-koin-android", "seedvault-lib-koin-android",
// bip39 // bip39
"seedvault-lib-kotlin-bip39", "seedvault-lib-kotlin-bip39",
// WebDAV
"seedvault-lib-dav4jvm",
"seedvault-lib-okhttp",
"seedvault-lib-okio",
], ],
manifest: "app/src/main/AndroidManifest.xml", manifest: "app/src/main/AndroidManifest.xml",

View file

@ -159,6 +159,9 @@ dependencies {
implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar")) implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar"))
// dav4jvm - later versions of okhttp need kotlin > 1.9.0
implementation(fileTree("${rootProject.rootDir}/libs/dav4jvm").include("*.jar"))
/** /**
* Test Dependencies (do not concern the AOSP build) * Test Dependencies (do not concern the AOSP build)
*/ */

View file

@ -41,19 +41,20 @@ class KoinInstrumentationTestApp : App() {
viewModel { viewModel {
currentRestoreViewModel = currentRestoreViewModel =
spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get())) spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get(), get()))
currentRestoreViewModel!! currentRestoreViewModel!!
} }
viewModel { viewModel {
currentBackupStorageViewModel = val viewModel =
spyk(BackupStorageViewModel(context, get(), get(), get(), get())) BackupStorageViewModel(context, get(), get(), get(), get(), get(), get(), get())
currentBackupStorageViewModel = spyk(viewModel)
currentBackupStorageViewModel!! currentBackupStorageViewModel!!
} }
viewModel { viewModel {
currentRestoreStorageViewModel = currentRestoreStorageViewModel =
spyk(RestoreStorageViewModel(context, get(), get())) spyk(RestoreStorageViewModel(context, get(), get(), get(), get()))
currentRestoreStorageViewModel!! currentRestoreStorageViewModel!!
} }
} }

View file

@ -1,5 +1,6 @@
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
@ -28,20 +29,24 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Suppress("BlockingMethodInNonBlockingContext")
@MediumTest @MediumTest
class PluginTest : KoinComponent { class PluginTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val mockedSettingsManager: SettingsManager = mockk() private val mockedSettingsManager: SettingsManager = mockk()
private val storage = DocumentsStorage(context, mockedSettingsManager) private val storage = DocumentsStorage(
appContext = context,
settingsManager = mockedSettingsManager,
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
)
private val storagePlugin: StoragePlugin = DocumentsProviderStoragePlugin(context, storage) private val storagePlugin: StoragePlugin<Uri> = DocumentsProviderStoragePlugin(context, storage)
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin = private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
DocumentsProviderLegacyPlugin(context, storage) storage
}
private val token = System.currentTimeMillis() - 365L * 24L * 60L * 60L * 1000L private val token = System.currentTimeMillis() - 365L * 24L * 60L * 60L * 1000L
private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build() private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
@ -49,7 +54,7 @@ class PluginTest : KoinComponent {
@Before @Before
fun setup() = runBlocking { fun setup() = runBlocking {
every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage() every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage()
storage.rootBackupDir?.deleteContents(context) storage.rootBackupDir?.deleteContents(context)
?: error("Select a storage location in the app first!") ?: error("Select a storage location in the app first!")
} }
@ -76,8 +81,6 @@ class PluginTest : KoinComponent {
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) { fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
// no backups available initially // no backups available initially
assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size) assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size)
val s = settingsManager.getStorage() ?: error("no storage")
assertFalse(storagePlugin.hasBackup(s))
// prepare returned tokens requested when initializing device // prepare returned tokens requested when initializing device
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1) every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
@ -92,7 +95,6 @@ class PluginTest : KoinComponent {
// one backup available now // one backup available now
assertEquals(1, storagePlugin.getAvailableBackups()?.toList()?.size) assertEquals(1, storagePlugin.getAvailableBackups()?.toList()?.size)
assertTrue(storagePlugin.hasBackup(s))
// initializing again (with another restore set) does add a restore set // initializing again (with another restore set) does add a restore set
storagePlugin.startNewRestoreSet(token + 1) storagePlugin.startNewRestoreSet(token + 1)
@ -100,7 +102,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)
assertTrue(storagePlugin.hasBackup(s))
// initializing again (without new restore set) doesn't change number of restore sets // initializing again (without new restore set) doesn't change number of restore sets
storagePlugin.initializeDevice() storagePlugin.initializeDevice()

View file

@ -22,7 +22,6 @@ import com.stevesoltys.seedvault.e2e.screen.impl.DocumentPickerScreen
import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import com.stevesoltys.seedvault.restore.RestoreViewModel import com.stevesoltys.seedvault.restore.RestoreViewModel
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
@ -69,8 +68,6 @@ internal interface LargeTestBase : KoinComponent {
val keyManager: KeyManager get() = get() val keyManager: KeyManager get() = get()
val documentsStorage: DocumentsStorage get() = get()
val spyMetadataManager: MetadataManager get() = get() val spyMetadataManager: MetadataManager get() = get()
val backupManager: IBackupManager get() = get() val backupManager: IBackupManager get() = get()
@ -84,7 +81,6 @@ internal interface LargeTestBase : KoinComponent {
fun resetApplicationState() { fun resetApplicationState() {
backupManager.setAutoRestore(false) backupManager.setAutoRestore(false)
settingsManager.setNewToken(null) settingsManager.setNewToken(null)
documentsStorage.reset(null)
val sharedPreferences = permitDiskReads { val sharedPreferences = permitDiskReads {
PreferenceManager.getDefaultSharedPreferences(targetContext) PreferenceManager.getDefaultSharedPreferences(targetContext)

View file

@ -17,7 +17,7 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
confirmCode() confirmCode()
} }
if (settingsManager.getStorage() == null) { if (settingsManager.getSafStorage() == null) {
chooseStorageLocation() chooseStorageLocation()
} else { } else {
changeBackupLocation() changeBackupLocation()

View file

@ -39,13 +39,16 @@ import java.io.IOException
import kotlin.random.Random import kotlin.random.Random
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@Suppress("BlockingMethodInNonBlockingContext")
@MediumTest @MediumTest
class DocumentsStorageTest : KoinComponent { class DocumentsStorageTest : KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val settingsManager by inject<SettingsManager>() private val settingsManager by inject<SettingsManager>()
private val storage = DocumentsStorage(context, settingsManager) private val storage = DocumentsStorage(
appContext = context,
settingsManager = settingsManager,
safStorage = settingsManager.getSafStorage() ?: error("No SAF storage"),
)
private val filename = getRandomBase64() private val filename = getRandomBase64()
private lateinit var file: DocumentFile private lateinit var file: DocumentFile

View file

@ -5,6 +5,7 @@ import android.util.Log
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 com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.AppStatus import com.stevesoltys.seedvault.settings.AppStatus
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import io.mockk.every import io.mockk.every
@ -24,7 +25,9 @@ class PackageServiceTest : KoinComponent {
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val storagePlugin: StoragePlugin by inject() private val storagePluginManager: StoragePluginManager by inject()
private val storagePlugin: StoragePlugin<*> get() = storagePluginManager.appPlugin
@Test @Test
fun testNotAllowedPackages() { fun testNotAllowedPackages() {

View file

@ -16,6 +16,9 @@
<!-- This is needed to check for internet access when backup is stored on network storage --> <!-- This is needed to check for internet access when backup is stored on network storage -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Used for internal WebDAV plugin -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- This is needed to inform users about backup status and errors --> <!-- This is needed to inform users about backup status and errors -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@ -97,7 +100,8 @@
<activity <activity
android:name=".ui.storage.StorageActivity" android:name=".ui.storage.StorageActivity"
android:theme="@style/AppTheme.NoActionBar" /> android:theme="@style/AppTheme.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name=".ui.storage.PermissionGrantActivity" android:name=".ui.storage.PermissionGrantActivity"

View file

@ -13,13 +13,15 @@ import android.os.StrictMode
import android.os.UserHandle import android.os.UserHandle
import android.os.UserManager import android.os.UserManager
import android.provider.Settings import android.provider.Settings
import androidx.work.WorkManager
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
import androidx.work.WorkManager
import com.stevesoltys.seedvault.crypto.cryptoModule import com.stevesoltys.seedvault.crypto.cryptoModule
import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.metadataModule import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
import com.stevesoltys.seedvault.plugins.webdav.storagePluginModuleWebDav
import com.stevesoltys.seedvault.restore.RestoreViewModel import com.stevesoltys.seedvault.restore.RestoreViewModel
import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.restore.install.installModule
import com.stevesoltys.seedvault.settings.AppListRetriever import com.stevesoltys.seedvault.settings.AppListRetriever
@ -54,15 +56,41 @@ open class App : Application() {
private val appModule = module { private val appModule = module {
single { SettingsManager(this@App) } single { SettingsManager(this@App) }
single { BackupNotificationManager(this@App) } single { BackupNotificationManager(this@App) }
single { StoragePluginManager(this@App, get(), get(), get()) }
single { BackupStateManager(this@App) }
single { Clock() } single { Clock() }
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) } factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
factory { AppListRetriever(this@App, get(), get(), get()) } factory { AppListRetriever(this@App, get(), get(), get()) }
viewModel { SettingsViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) } viewModel {
SettingsViewModel(
app = this@App,
settingsManager = get(),
keyManager = get(),
pluginManager = get(),
metadataManager = get(),
appListRetriever = get(),
storageBackup = get(),
backupManager = get(),
backupInitializer = get(),
backupStateManager = get(),
)
}
viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) } viewModel { RecoveryCodeViewModel(this@App, get(), get(), get(), get(), get(), get()) }
viewModel { BackupStorageViewModel(this@App, get(), get(), get(), get()) } viewModel {
viewModel { RestoreStorageViewModel(this@App, get(), get()) } BackupStorageViewModel(
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get()) } app = this@App,
backupManager = get(),
backupInitializer = get(),
storageBackup = get(),
safHandler = get(),
webDavHandler = get(),
settingsManager = get(),
storagePluginManager = get(),
)
}
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) }
viewModel { FileSelectionViewModel(this@App, get()) } viewModel { FileSelectionViewModel(this@App, get()) }
} }
@ -100,7 +128,8 @@ open class App : Application() {
cryptoModule, cryptoModule,
headerModule, headerModule,
metadataModule, metadataModule,
documentsProviderModule, // storage plugin storagePluginModuleSaf,
storagePluginModuleWebDav,
backupModule, backupModule,
restoreModule, restoreModule,
installModule, installModule,

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault
import android.content.Context
import android.util.Log
import androidx.work.WorkInfo.State.RUNNING
import androidx.work.WorkManager
import com.stevesoltys.seedvault.storage.StorageBackupService
import com.stevesoltys.seedvault.transport.ConfigurableBackupTransportService
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
private const val TAG = "BackupStateManager"
class BackupStateManager(
context: Context,
) {
private val workManager = WorkManager.getInstance(context)
val isBackupRunning: Flow<Boolean> = combine(
flow = ConfigurableBackupTransportService.isRunning,
flow2 = StorageBackupService.isRunning,
flow3 = workManager.getWorkInfosForUniqueWorkFlow(UNIQUE_WORK_NAME),
) { appBackupRunning, filesBackupRunning, workInfos ->
val workInfoState = workInfos.getOrNull(0)?.state
Log.i(
TAG, "appBackupRunning: $appBackupRunning, " +
"filesBackupRunning: $filesBackupRunning, " +
"workInfoState: ${workInfoState?.name}"
)
appBackupRunning || filesBackupRunning || workInfoState == RUNNING
}
}

View file

@ -1,13 +1,17 @@
package com.stevesoltys.seedvault.plugins package com.stevesoltys.seedvault.plugins
import android.app.backup.RestoreSet import android.app.backup.RestoreSet
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.settings.Storage
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
interface StoragePlugin { interface StoragePlugin<T> {
/**
* Returns true if the plugin is working, or false if it isn't.
* @throws Exception any kind of exception to provide more info on the error
*/
suspend fun test(): Boolean
/** /**
* Start a new [RestoreSet] with the given token. * Start a new [RestoreSet] with the given token.
@ -47,14 +51,6 @@ interface StoragePlugin {
@Throws(IOException::class) @Throws(IOException::class)
suspend fun removeData(token: Long, name: String) suspend fun removeData(token: Long, name: String)
/**
* Searches if there's really a backup available in the given storage location.
* Returns true if at least one was found and false otherwise.
*/
@WorkerThread
@Throws(IOException::class)
suspend fun hasBackup(storage: Storage): Boolean
/** /**
* Get the set of all backups currently available for restore. * Get the set of all backups currently available for restore.
* *
@ -75,4 +71,7 @@ interface StoragePlugin {
} }
class EncryptedMetadata(val token: Long, val inputStreamRetriever: () -> InputStream) class EncryptedMetadata(val token: Long, val inputStreamRetriever: suspend () -> InputStream)
internal val tokenRegex = Regex("([0-9]{13})") // good until the year 2286
internal val chunkFolderRegex = Regex("[a-f0-9]{2}")

View file

@ -0,0 +1,134 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins
import android.content.Context
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
import com.stevesoltys.seedvault.settings.StoragePluginType
class StoragePluginManager(
private val context: Context,
private val settingsManager: SettingsManager,
safFactory: SafFactory,
webDavFactory: WebDavFactory,
) {
private var mAppPlugin: StoragePlugin<*>?
private var mFilesPlugin: org.calyxos.backup.storage.api.StoragePlugin?
private var mStorageProperties: StorageProperties<*>?
val appPlugin: StoragePlugin<*>
@Synchronized
get() {
return mAppPlugin ?: error("App plugin was loaded, but still null")
}
val filesPlugin: org.calyxos.backup.storage.api.StoragePlugin
@Synchronized
get() {
return mFilesPlugin ?: error("Files plugin was loaded, but still null")
}
val storageProperties: StorageProperties<*>?
@Synchronized
get() {
return mStorageProperties
}
val isOnRemovableDrive: Boolean get() = storageProperties?.isUsb == true
init {
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)
mStorageProperties = safStorage
}
StoragePluginType.WEB_DAV -> {
val webDavProperties =
settingsManager.webDavProperties ?: error("No WebDAV config saved")
mAppPlugin = webDavFactory.createAppStoragePlugin(webDavProperties.config)
mFilesPlugin = webDavFactory.createFilesStoragePlugin(webDavProperties.config)
mStorageProperties = webDavProperties
}
null -> {
mAppPlugin = null
mFilesPlugin = null
mStorageProperties = null
}
}
}
fun isValidAppPluginSet(): Boolean {
if (mAppPlugin == null || mFilesPlugin == null) return false
if (mAppPlugin is DocumentsProviderStoragePlugin) {
val storage = settingsManager.getSafStorage() ?: return false
if (storage.isUsb) return true
return permitDiskReads {
storage.getDocumentFile(context).isDirectory
}
}
return true
}
/**
* Changes the storage plugins and current [StorageProperties].
*
* IMPORTANT: Do no call this while current plugins are being used,
* e.g. while backup/restore operation is still running.
*/
fun <T> changePlugins(
storageProperties: StorageProperties<T>,
appPlugin: StoragePlugin<T>,
filesPlugin: org.calyxos.backup.storage.api.StoragePlugin,
) {
settingsManager.setStoragePlugin(appPlugin)
mStorageProperties = storageProperties
mAppPlugin = appPlugin
mFilesPlugin = filesPlugin
}
/**
* Check if we are able to do backups now by examining possible pre-conditions
* such as plugged-in flash drive or internet access.
*
* Should be run off the UI thread (ideally I/O) because of disk access.
*
* @return true if a backup is possible, false if not.
*/
@WorkerThread
fun canDoBackupNow(): Boolean {
val storage = storageProperties ?: return false
return !isOnUnavailableUsb() &&
!storage.isUnavailableNetwork(context, settingsManager.useMeteredNetwork)
}
/**
* Checks if storage is on a flash drive.
*
* Should be run off the UI thread (ideally I/O) because of disk access.
*
* @return true if flash drive is not plugged in,
* false if storage isn't on flash drive or it isn't plugged in.
*/
@WorkerThread
fun isOnUnavailableUsb(): Boolean {
val storage = storageProperties ?: return false
val systemContext = context.getStorageContext { storage.isUsb }
return storage.isUnavailableUsb(systemContext)
}
}

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
import androidx.annotation.WorkerThread
abstract class StorageProperties<T> {
abstract val config: T
abstract val name: String
abstract val isUsb: Boolean
abstract val requiresNetwork: Boolean
@WorkerThread
abstract fun isUnavailableUsb(context: Context): Boolean
/**
* Returns true if this is storage that requires network access,
* but it isn't available right now.
*/
fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean {
return requiresNetwork && !hasUnmeteredInternet(context, allowMetered)
}
private fun hasUnmeteredInternet(context: Context, allowMetered: Boolean): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val isMetered = cm.isActiveNetworkMetered
val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return capabilities.hasCapability(NET_CAPABILITY_INTERNET) && (allowMetered || !isMetered)
}
}

View file

@ -10,12 +10,13 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
@WorkerThread @WorkerThread
@Suppress("BlockingMethodInNonBlockingContext", "Deprecation") // all methods do I/O @Suppress("Deprecation")
internal class DocumentsProviderLegacyPlugin( internal class DocumentsProviderLegacyPlugin(
private val context: Context, private val context: Context,
private val storage: DocumentsStorage, private val storageGetter: () -> DocumentsStorage,
) : LegacyStoragePlugin { ) : LegacyStoragePlugin {
private val storage get() = storageGetter()
private var packageDir: DocumentFile? = null private var packageDir: DocumentFile? = null
private var packageChildren: List<DocumentFile>? = null private var packageChildren: List<DocumentFile>? = null

View file

@ -1,14 +1,22 @@
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.settings.SettingsManager
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module import org.koin.dsl.module
val documentsProviderModule = module { val storagePluginModuleSaf = module {
single { DocumentsStorage(androidContext(), get()) } single { SafFactory(androidContext(), get(), get()) }
single { SafHandler(androidContext(), get(), get(), get()) }
single<StoragePlugin> { DocumentsProviderStoragePlugin(androidContext(), get()) }
@Suppress("Deprecation") @Suppress("Deprecation")
single<LegacyStoragePlugin> { DocumentsProviderLegacyPlugin(androidContext(), get()) } single<LegacyStoragePlugin> {
DocumentsProviderLegacyPlugin(
context = androidContext(),
storageGetter = {
val safStorage = get<SettingsManager>().getSafStorage() ?: error("No SAF storage")
DocumentsStorage(androidContext(), get(), safStorage)
},
)
}
} }

View file

@ -2,12 +2,15 @@ package com.stevesoltys.seedvault.plugins.saf
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.getStorageContext 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.settings.Storage import com.stevesoltys.seedvault.plugins.chunkFolderRegex
import com.stevesoltys.seedvault.plugins.tokenRegex
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
@ -15,22 +18,23 @@ import java.io.OutputStream
private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class DocumentsProviderStoragePlugin( internal class DocumentsProviderStoragePlugin(
private val appContext: Context, private val appContext: Context,
private val storage: DocumentsStorage, private val storage: DocumentsStorage,
) : StoragePlugin { ) : StoragePlugin<Uri> {
/** /**
* Attention: This context might be from a different user. Use with care. * Attention: This context might be from a different user. Use with care.
*/ */
private val context: Context private val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
get() = appContext.getStorageContext {
storage.storage?.isUsb == true
}
private val packageManager: PackageManager = appContext.packageManager private val packageManager: PackageManager = appContext.packageManager
override suspend fun test(): Boolean {
val dir = storage.rootBackupDir
return dir != null && dir.exists()
}
@Throws(IOException::class) @Throws(IOException::class)
override suspend fun startNewRestoreSet(token: Long) { override suspend fun startNewRestoreSet(token: Long) {
// reset current storage // reset current storage
@ -71,16 +75,6 @@ internal class DocumentsProviderStoragePlugin(
if (!file.delete()) throw IOException("Failed to delete $name") if (!file.delete()) throw IOException("Failed to delete $name")
} }
@Throws(IOException::class)
override suspend fun hasBackup(storage: Storage): Boolean {
// potentially get system user context if needed here
val c = appContext.getStorageContext { storage.isUsb }
val parent = DocumentFile.fromTreeUri(c, storage.uri) ?: throw AssertionError()
val rootDir = parent.findFileBlocking(c, DIRECTORY_ROOT) ?: return false
val backupSets = getBackups(c, rootDir)
return backupSets.isNotEmpty()
}
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? { override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
val rootDir = storage.rootBackupDir ?: return null val rootDir = storage.rootBackupDir ?: return null
val backupSets = getBackups(context, rootDir) val backupSets = getBackups(context, rootDir)
@ -104,7 +98,6 @@ internal class DocumentsProviderStoragePlugin(
class BackupSet(val token: Long, val metadataFile: DocumentFile) class BackupSet(val token: Long, val metadataFile: DocumentFile)
@Suppress("BlockingMethodInNonBlockingContext")
internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> { internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
val backupSets = ArrayList<BackupSet>() val backupSets = ArrayList<BackupSet>()
val files = try { val files = try {
@ -137,9 +130,6 @@ internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<B
return backupSets return backupSets
} }
private val tokenRegex = Regex("([0-9]{13})") // good until the year 2286
private val chunkFolderRegex = Regex("[a-f0-9]{2}")
private fun DocumentFile.getTokenOrNull(name: String?): Long? { private fun DocumentFile.getTokenOrNull(name: String?): Long? {
val looksLikeToken = name != null && tokenRegex.matches(name) val looksLikeToken = name != null && tokenRegex.matches(name)
// check for isDirectory only if we already have a valid token (causes DB query) // check for isDirectory only if we already have a valid token (causes DB query)
@ -160,5 +150,5 @@ private fun DocumentFile.getTokenOrNull(name: String?): Long? {
private fun isUnexpectedFile(name: String): Boolean { private fun isUnexpectedFile(name: String): Boolean {
return name != FILE_NO_MEDIA && return name != FILE_NO_MEDIA &&
!chunkFolderRegex.matches(name) && !chunkFolderRegex.matches(name) &&
!name.endsWith(".SeedSnap") !name.endsWith(SNAPSHOT_EXT)
} }

View file

@ -1,5 +1,3 @@
@file:Suppress("BlockingMethodInNonBlockingContext")
package com.stevesoltys.seedvault.plugins.saf package com.stevesoltys.seedvault.plugins.saf
import android.content.ContentResolver import android.content.ContentResolver
@ -18,7 +16,6 @@ import androidx.annotation.VisibleForTesting
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.getStorageContext import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@ -44,27 +41,19 @@ private val TAG = DocumentsStorage::class.java.simpleName
internal class DocumentsStorage( internal class DocumentsStorage(
private val appContext: Context, private val appContext: Context,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
internal val safStorage: SafStorage,
) { ) {
internal var storage: Storage? = null
get() {
if (field == null) field = settingsManager.getStorage()
return field
}
/** /**
* Attention: This context might be from a different user. Use with care. * Attention: This context might be from a different user. Use with care.
*/ */
private val context: Context private val context: Context get() = appContext.getStorageContext { safStorage.isUsb }
get() = appContext.getStorageContext {
storage?.isUsb == true
}
private val contentResolver: ContentResolver get() = context.contentResolver private val contentResolver: ContentResolver get() = context.contentResolver
internal var rootBackupDir: DocumentFile? = null internal var rootBackupDir: DocumentFile? = null
get() = runBlocking { get() = runBlocking {
if (field == null) { if (field == null) {
val parent = storage?.getDocumentFile(context) val parent = safStorage.getDocumentFile(context)
?: return@runBlocking null
field = try { field = try {
parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply { parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
// create .nomedia file to prevent Android's MediaScanner // create .nomedia file to prevent Android's MediaScanner
@ -104,13 +93,12 @@ internal class DocumentsStorage(
* Resets this storage abstraction, forcing it to re-fetch cached values on next access. * Resets this storage abstraction, forcing it to re-fetch cached values on next access.
*/ */
fun reset(newToken: Long?) { fun reset(newToken: Long?) {
storage = null
currentToken = newToken currentToken = newToken
rootBackupDir = null rootBackupDir = null
currentSetDir = null currentSetDir = null
} }
fun getAuthority(): String? = storage?.uri?.authority 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 = currentToken ?: error("no token")): DocumentFile? {

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
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)
}
internal fun createFilesStoragePlugin(
safStorage: SafStorage,
documentsStorage: DocumentsStorage = DocumentsStorage(context, settingsManager, safStorage),
): org.calyxos.backup.storage.api.StoragePlugin {
return SeedvaultSafStoragePlugin(context, documentsStorage, keyManager)
}
}

View file

@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.content.Context.USB_SERVICE
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.hardware.usb.UsbManager
import android.net.Uri
import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.isMassStorage
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.FlashDrive
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.storage.StorageOption
import java.io.IOException
private const val TAG = "SafHandler"
internal class SafHandler(
private val context: Context,
private val safFactory: SafFactory,
private val settingsManager: SettingsManager,
private val storagePluginManager: StoragePluginManager,
) {
fun onConfigReceived(uri: Uri, safOption: StorageOption.SafOption): SafStorage {
// persist permission to access backup folder across reboots
val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
val name = if (safOption.isInternal()) {
"${safOption.title} (${context.getString(R.string.settings_backup_location_internal)})"
} else {
safOption.title
}
return SafStorage(uri, name, safOption.isUsb, safOption.requiresNetwork)
}
/**
* Searches if there's really an app backup available in the given storage location.
* Returns true if at least one was found and false otherwise.
*/
@WorkerThread
@Throws(IOException::class)
suspend fun hasAppBackup(safStorage: SafStorage): Boolean {
val storage = DocumentsStorage(context, settingsManager, safStorage)
val appPlugin = safFactory.createAppStoragePlugin(safStorage, storage)
val backups = appPlugin.getAvailableBackups()
return backups != null && backups.iterator().hasNext()
}
fun save(safStorage: SafStorage) {
settingsManager.setSafStorage(safStorage)
if (safStorage.isUsb) {
Log.d(TAG, "Selected storage is a removable USB device.")
val wasSaved = saveUsbDevice()
// reset stored flash drive, if we did not update it
if (!wasSaved) settingsManager.setFlashDrive(null)
} else {
settingsManager.setFlashDrive(null)
}
Log.d(TAG, "New storage location saved: ${safStorage.uri}")
}
private fun saveUsbDevice(): Boolean {
val manager = context.getSystemService(USB_SERVICE) as UsbManager
manager.deviceList.values.forEach { device ->
if (device.isMassStorage()) {
val flashDrive = FlashDrive.from(device)
settingsManager.setFlashDrive(flashDrive)
Log.d(TAG, "Saved flash drive: $flashDrive")
return true
}
}
Log.e(TAG, "No USB device found even though we were expecting one.")
return false
}
fun setPlugin(safStorage: SafStorage) {
val storage = DocumentsStorage(context, settingsManager, safStorage)
storagePluginManager.changePlugins(
storageProperties = safStorage,
appPlugin = safFactory.createAppStoragePlugin(safStorage, storage),
filesPlugin = safFactory.createFilesStoragePlugin(safStorage, storage),
)
}
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.plugins.StorageProperties
data class SafStorage(
override val config: Uri,
override val name: String,
override val isUsb: Boolean,
override val requiresNetwork: Boolean,
) : StorageProperties<Uri>() {
val uri: Uri = config
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, config)
?: throw AssertionError("Should only happen on API < 21.")
/**
* Returns true if this is USB storage that is not available, false otherwise.
*
* Must be run off UI thread (ideally I/O).
*/
@WorkerThread
override fun isUnavailableUsb(context: Context): Boolean {
return isUsb && !getDocumentFile(context).isDirectory
}
}

View file

@ -1,4 +1,9 @@
package com.stevesoltys.seedvault.ui.storage /*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.saf
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -9,8 +14,13 @@ import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver.getIcon
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import com.stevesoltys.seedvault.ui.storage.StorageOption
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
import com.stevesoltys.seedvault.ui.storage.StorageRootResolver.getIcon
private const val DAVX5_PACKAGE = "at.bitfire.davdroid" private const val DAVX5_PACKAGE = "at.bitfire.davdroid"
private const val DAVX5_ACTIVITY = "at.bitfire.davdroid.ui.webdav.WebdavMountsActivity" private const val DAVX5_ACTIVITY = "at.bitfire.davdroid.ui.webdav.WebdavMountsActivity"
@ -29,15 +39,15 @@ internal class SafStorageOptions(
private val packageManager = context.packageManager private val packageManager = context.packageManager
internal fun checkOrAddExtraRoots(roots: ArrayList<SafOption>) { internal fun checkOrAddExtraRoots(roots: ArrayList<StorageOption>) {
checkOrAddUsbRoot(roots) checkOrAddUsbRoot(roots)
checkOrAddDavX5Root(roots) checkOrAddDavX5Root(roots)
checkOrAddNextCloudRoot(roots) checkOrAddNextCloudRoot(roots)
checkOrAddRoundSyncRoots(roots) checkOrAddRoundSyncRoots(roots)
} }
private fun checkOrAddUsbRoot(roots: ArrayList<SafOption>) { private fun checkOrAddUsbRoot(roots: ArrayList<StorageOption>) {
if (doNotInclude(AUTHORITY_STORAGE, roots) { it.isUsb }) return if (doNotInclude(AUTHORITY_STORAGE, roots) { it is SafOption && it.isUsb }) return
val root = SafOption( val root = SafOption(
authority = AUTHORITY_STORAGE, authority = AUTHORITY_STORAGE,
@ -57,11 +67,11 @@ internal class SafStorageOptions(
/** /**
* Add a storage root for each child directory at the RoundSync root, if it exists. * Add a storage root for each child directory at the RoundSync root, if it exists.
*/ */
private fun checkOrAddRoundSyncRoots(roots: ArrayList<SafOption>) { private fun checkOrAddRoundSyncRoots(roots: ArrayList<StorageOption>) {
val roundSyncRoot = roots.firstOrNull { val roundSyncRoot = roots.firstOrNull {
it.authority == AUTHORITY_ROUND_SYNC it is SafOption && it.authority == AUTHORITY_ROUND_SYNC
} ?: return } as? SafOption ?: return
roots.remove(roundSyncRoot) roots.remove(roundSyncRoot)
@ -105,7 +115,7 @@ internal class SafStorageOptions(
* *
* If it *is* installed and this is restore, the user can set up a new account by clicking. * If it *is* installed and this is restore, the user can set up a new account by clicking.
*/ */
private fun checkOrAddDavX5Root(roots: ArrayList<SafOption>) { private fun checkOrAddDavX5Root(roots: ArrayList<StorageOption>) {
if (doNotInclude(AUTHORITY_DAVX5, roots)) return if (doNotInclude(AUTHORITY_DAVX5, roots)) return
val intent = Intent().apply { val intent = Intent().apply {
@ -155,7 +165,7 @@ internal class SafStorageOptions(
* because we don't know if there's just no account or an activated passcode * because we don't know if there's just no account or an activated passcode
* (which hides existing accounts). * (which hides existing accounts).
*/ */
private fun checkOrAddNextCloudRoot(roots: ArrayList<SafOption>) { private fun checkOrAddNextCloudRoot(roots: ArrayList<StorageOption>) {
if (doNotInclude(AUTHORITY_NEXTCLOUD, roots)) return if (doNotInclude(AUTHORITY_NEXTCLOUD, roots)) return
val intent = Intent().apply { val intent = Intent().apply {
@ -202,11 +212,12 @@ internal class SafStorageOptions(
private fun doNotInclude( private fun doNotInclude(
authority: String, authority: String,
roots: ArrayList<SafOption>, roots: ArrayList<StorageOption>,
doNotIncludeIfTrue: ((SafOption) -> Boolean)? = null, doNotIncludeIfTrue: ((StorageOption) -> Boolean)? = null,
): Boolean { ): Boolean {
if (!isAuthoritySupported(authority)) return true if (!isAuthoritySupported(authority)) return true
for (root in roots) { for (root in roots) {
if (root !is SafOption) continue
if (root.authority == authority && doNotIncludeIfTrue?.invoke(root) != false) { if (root.authority == authority && doNotIncludeIfTrue?.invoke(root) != false) {
return true return true
} }

View file

@ -1,4 +1,9 @@
package com.stevesoltys.seedvault.ui.storage /*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.saf
import android.Manifest.permission.MANAGE_DOCUMENTS import android.Manifest.permission.MANAGE_DOCUMENTS
import android.content.Context import android.content.Context
@ -19,8 +24,16 @@ import android.provider.DocumentsContract.Root.FLAG_REMOVABLE_USB
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_CREATE
import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD import android.provider.DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
import android.util.Log import android.util.Log
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.getStorageContext import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DAVX5
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_DOWNLOADS
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_NEXTCLOUD
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_ROUND_SYNC
import com.stevesoltys.seedvault.ui.storage.AUTHORITY_STORAGE
import com.stevesoltys.seedvault.ui.storage.ROOT_ID_DEVICE
import com.stevesoltys.seedvault.ui.storage.ROOT_ID_HOME
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
internal object StorageRootResolver { internal object StorageRootResolver {
@ -126,23 +139,23 @@ internal object StorageRootResolver {
fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? { fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
return getPackageIcon(context, authority, icon) ?: when { return getPackageIcon(context, authority, icon) ?: when {
authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> { authority == AUTHORITY_STORAGE && rootId == ROOT_ID_DEVICE -> {
context.getDrawable(R.drawable.ic_phone_android) getDrawable(context, R.drawable.ic_phone_android)
} }
authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> { authority == AUTHORITY_STORAGE && rootId != ROOT_ID_HOME -> {
context.getDrawable(R.drawable.ic_usb) getDrawable(context, R.drawable.ic_usb)
} }
authority == AUTHORITY_NEXTCLOUD -> { authority == AUTHORITY_NEXTCLOUD -> {
context.getDrawable(R.drawable.nextcloud) getDrawable(context, R.drawable.nextcloud)
} }
authority == AUTHORITY_DAVX5 -> { authority == AUTHORITY_DAVX5 -> {
context.getDrawable(R.drawable.davx5) getDrawable(context, R.drawable.davx5)
} }
authority == AUTHORITY_ROUND_SYNC -> { authority == AUTHORITY_ROUND_SYNC -> {
context.getDrawable(R.drawable.round_sync) getDrawable(context, R.drawable.round_sync)
} }
else -> null else -> null

View file

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.webdav
data class WebDavConfig(
val url: String,
val username: String,
val password: String,
)

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.webdav
import android.annotation.SuppressLint
import android.content.Context
import android.provider.Settings
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.plugins.StoragePlugin
class WebDavFactory(
private val context: Context,
private val keyManager: KeyManager,
) {
fun createAppStoragePlugin(config: WebDavConfig): StoragePlugin<WebDavConfig> {
return WebDavStoragePlugin(context, config)
}
fun createFilesStoragePlugin(
config: WebDavConfig,
): org.calyxos.backup.storage.api.StoragePlugin {
@SuppressLint("HardwareIds")
val androidId =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
return com.stevesoltys.seedvault.storage.WebDavStoragePlugin(
keyManager = keyManager,
androidId = androidId,
webDavConfig = config,
)
}
}

View file

@ -0,0 +1,95 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.webdav
import android.content.Context
import android.util.Log
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.SettingsManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import okhttp3.HttpUrl.Companion.toHttpUrl
import java.io.IOException
internal sealed interface WebDavConfigState {
object Empty : WebDavConfigState
object Checking : WebDavConfigState
class Success(
val properties: WebDavProperties,
val plugin: WebDavStoragePlugin,
) : WebDavConfigState
class Error(val e: Exception?) : WebDavConfigState
}
private val TAG = WebDavHandler::class.java.simpleName
internal class WebDavHandler(
private val context: Context,
private val webDavFactory: WebDavFactory,
private val settingsManager: SettingsManager,
private val storagePluginManager: StoragePluginManager,
) {
companion object {
fun createWebDavProperties(context: Context, config: WebDavConfig): WebDavProperties {
val host = config.url.toHttpUrl().host
return WebDavProperties(
config = config,
name = context.getString(R.string.storage_webdav_name, host),
)
}
}
private val mConfigState = MutableStateFlow<WebDavConfigState>(WebDavConfigState.Empty)
val configState = mConfigState.asStateFlow()
suspend fun onConfigReceived(config: WebDavConfig) {
mConfigState.value = WebDavConfigState.Checking
val plugin = webDavFactory.createAppStoragePlugin(config) as WebDavStoragePlugin
try {
if (plugin.test()) {
val properties = createWebDavProperties(context, config)
mConfigState.value = WebDavConfigState.Success(properties, plugin)
} else {
mConfigState.value = WebDavConfigState.Error(null)
}
} catch (e: Exception) {
Log.e(TAG, "Error testing WebDAV config at ${config.url}", e)
mConfigState.value = WebDavConfigState.Error(e)
}
}
fun resetConfigState() {
mConfigState.value = WebDavConfigState.Empty
}
/**
* Searches if there's really an app backup available in the given storage location.
* Returns true if at least one was found and false otherwise.
*/
@WorkerThread
@Throws(IOException::class)
suspend fun hasAppBackup(appPlugin: WebDavStoragePlugin): Boolean {
val backups = appPlugin.getAvailableBackups()
return backups != null && backups.iterator().hasNext()
}
fun save(properties: WebDavProperties) {
settingsManager.saveWebDavConfig(properties.config)
}
fun setPlugin(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
storagePluginManager.changePlugins(
storageProperties = properties,
appPlugin = plugin,
filesPlugin = webDavFactory.createFilesStoragePlugin(properties.config),
)
}
}

View file

@ -0,0 +1,9 @@
package com.stevesoltys.seedvault.plugins.webdav
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val storagePluginModuleWebDav = module {
single { WebDavFactory(androidContext(), get()) }
single { WebDavHandler(androidContext(), get(), get(), get()) }
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.webdav
import android.content.Context
import com.stevesoltys.seedvault.plugins.StorageProperties
data class WebDavProperties(
override val config: WebDavConfig,
override val name: String,
) : StorageProperties<WebDavConfig>() {
override val isUsb: Boolean = false
override val requiresNetwork: Boolean = true
override fun isUnavailableUsb(context: Context): Boolean = false
}

View file

@ -0,0 +1,181 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.webdav
import android.util.Log
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.ResourceType
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.ConnectionSpec
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.internal.closeQuietly
import okio.BufferedSink
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
const val DEBUG_LOG = true
const val DIRECTORY_ROOT = ".SeedVaultAndroidBackup"
@OptIn(DelicateCoroutinesApi::class)
internal abstract class WebDavStorage(
webDavConfig: WebDavConfig,
root: String = DIRECTORY_ROOT,
) {
companion object {
val TAG: String = WebDavStorage::class.java.simpleName
}
private val authHandler = BasicDigestAuthHandler(
domain = null, // Optional, to only authenticate against hosts with this domain.
username = webDavConfig.username,
password = webDavConfig.password,
)
protected val okHttpClient = OkHttpClient.Builder()
.followRedirects(false)
.authenticator(authHandler)
.addNetworkInterceptor(authHandler)
.connectTimeout(15, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.pingInterval(45, TimeUnit.SECONDS)
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS))
.retryOnConnectionFailure(true)
.build()
protected val baseUrl = webDavConfig.url
protected val url = "${webDavConfig.url}/$root"
@Throws(IOException::class)
protected suspend fun getOutputStream(location: HttpUrl): OutputStream {
val davCollection = DavCollection(okHttpClient, location)
val pipedInputStream = PipedInputStream()
val pipedOutputStream = PipedCloseActionOutputStream(pipedInputStream)
val body = object : RequestBody() {
override fun contentType() = "application/octet-stream".toMediaType()
override fun writeTo(sink: BufferedSink) {
pipedInputStream.use { inputStream ->
sink.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
}
}
}
val deferred = GlobalScope.async(Dispatchers.IO) {
davCollection.put(body) { response ->
debugLog { "getOutputStream($location) = $response" }
}
}
pipedOutputStream.doOnClose {
runBlocking { // blocking i/o wait
deferred.await()
}
}
return pipedOutputStream
}
@Throws(IOException::class)
protected fun getInputStream(location: HttpUrl): InputStream {
val davCollection = DavCollection(okHttpClient, location)
val pipedInputStream = PipedExceptionInputStream()
val pipedOutputStream = PipedOutputStream(pipedInputStream)
GlobalScope.launch(Dispatchers.IO) {
try {
davCollection.get(accept = "", headers = null) { response ->
val inputStream = response.body?.byteStream()
?: throw IOException("No response body")
debugLog { "getInputStream($location) = $response" }
pipedOutputStream.use { outputStream ->
inputStream.copyTo(outputStream)
}
}
} catch (e: Exception) {
debugLog { "Exception while getting input stream: $e" }
// pass exception to stream, so it gets thrown when stream is closed
// if we'd just throw it here, it would be uncaught, on a different thread
pipedInputStream.throwable = e
pipedOutputStream.closeQuietly()
}
}
return pipedInputStream
}
protected suspend fun DavCollection.createFolder(xmlBody: String? = null): okhttp3.Response {
return try {
suspendCoroutine { cont ->
mkCol(xmlBody) { response ->
cont.resume(response)
}
}
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
protected inline fun debugLog(block: () -> String) {
if (DEBUG_LOG) Log.d(TAG, block())
}
protected fun Response.isFolder(): Boolean {
return this[ResourceType::class.java]?.types?.contains(ResourceType.COLLECTION) == true
}
private class PipedCloseActionOutputStream(
inputStream: PipedInputStream,
) : PipedOutputStream(inputStream) {
private var onClose: (() -> Unit)? = null
@Throws(IOException::class)
override fun close() {
super.close()
try {
onClose?.invoke()
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
fun doOnClose(function: () -> Unit) {
this.onClose = function
}
}
private class PipedExceptionInputStream : PipedInputStream() {
var throwable: Throwable? = null
override fun close() {
super.close()
throwable?.let { e ->
if (e is IOException) throw e
else throw IOException(e)
}
}
}
}

View file

@ -0,0 +1,200 @@
package com.stevesoltys.seedvault.plugins.webdav
import android.content.Context
import android.util.Log
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.ResourceType
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.plugins.saf.FILE_NO_MEDIA
import com.stevesoltys.seedvault.plugins.tokenRegex
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
internal class WebDavStoragePlugin(
context: Context,
webDavConfig: WebDavConfig,
root: String = DIRECTORY_ROOT,
) : WebDavStorage(webDavConfig, root), StoragePlugin<WebDavConfig> {
override suspend fun test(): Boolean {
val location = baseUrl.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val webDavSupported = suspendCoroutine { cont ->
davCollection.options { davCapabilities, response ->
debugLog { "test() = $davCapabilities $response" }
if (davCapabilities.contains("1")) cont.resume(true)
else if (davCapabilities.contains("2")) cont.resume(true)
else if (davCapabilities.contains("3")) cont.resume(true)
else cont.resume(false)
}
}
return webDavSupported
}
@Throws(IOException::class)
override suspend fun startNewRestoreSet(token: Long) {
val location = "$url/$token".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val response = davCollection.createFolder()
debugLog { "startNewRestoreSet($token) = $response" }
}
@Throws(IOException::class)
override suspend fun initializeDevice() {
// TODO does it make sense to delete anything
// when [startNewRestoreSet] is always called first? Maybe unify both calls?
val location = url.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
davCollection.head { response ->
debugLog { "Root exists: $response" }
}
} catch (e: NotFoundException) {
val response = davCollection.createFolder()
debugLog { "initializeDevice() = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
@Throws(IOException::class)
override suspend fun hasData(token: Long, name: String): Boolean {
val location = "$url/$token/$name".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
return try {
val response = suspendCoroutine { cont ->
davCollection.head { response ->
cont.resume(response)
}
}
debugLog { "hasData($token, $name) = $response" }
response.isSuccessful
} catch (e: NotFoundException) {
debugLog { "hasData($token, $name) = $e" }
false
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
@Throws(IOException::class)
override suspend fun getOutputStream(token: Long, name: String): OutputStream {
val location = "$url/$token/$name".toHttpUrl()
return try {
getOutputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting OutputStream for $token and $name: ", e)
}
}
@Throws(IOException::class)
override suspend fun getInputStream(token: Long, name: String): InputStream {
val location = "$url/$token/$name".toHttpUrl()
return try {
getInputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting InputStream for $token and $name: ", e)
}
}
@Throws(IOException::class)
override suspend fun removeData(token: Long, name: String) {
val location = "$url/$token/$name".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
val response = suspendCoroutine { cont ->
davCollection.delete { response ->
cont.resume(response)
}
}
debugLog { "removeData($token, $name) = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
override suspend fun getAvailableBackups(): Sequence<EncryptedMetadata>? {
return try {
doGetAvailableBackups()
} catch (e: Exception) {
Log.e(TAG, "Error getting available backups: ", e)
null
}
}
private suspend fun doGetAvailableBackups(): Sequence<EncryptedMetadata> {
val location = url.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
// get all restore set tokens in root folder
val tokens = ArrayList<Long>()
davCollection.propfind(
depth = 2,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getAvailableBackups() = $response" }
// This callback will be called for every file in the folder
if (relation != SELF && !response.isFolder() && response.href.pathSize >= 2 &&
response.hrefName() == FILE_BACKUP_METADATA
) {
val tokenName = response.href.pathSegments[response.href.pathSegments.size - 2]
getTokenOrNull(tokenName)?.let { token ->
tokens.add(token)
}
}
}
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)
}
}
}
private fun getTokenOrNull(name: String): Long? {
val looksLikeToken = name.isNotEmpty() && tokenRegex.matches(name)
if (looksLikeToken) {
return try {
name.toLong()
} catch (e: NumberFormatException) {
throw AssertionError(e) // regex must be wrong
}
}
if (isUnexpectedFile(name)) {
Log.w(TAG, "Found invalid backup set folder: $name")
}
return null
}
private fun isUnexpectedFile(name: String): Boolean {
return name != FILE_NO_MEDIA &&
!chunkFolderRegex.matches(name) &&
!name.endsWith(SNAPSHOT_EXT)
}
override val providerPackageName: String = context.packageName // 100% built-in plugin
}

View file

@ -28,6 +28,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
@ -81,8 +82,9 @@ internal class RestoreViewModel(
private val restoreCoordinator: RestoreCoordinator, private val restoreCoordinator: RestoreCoordinator,
private val apkRestore: ApkRestore, private val apkRestore: ApkRestore,
storageBackup: StorageBackup, storageBackup: StorageBackup,
pluginManager: StoragePluginManager,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : RequireProvisioningViewModel(app, settingsManager, keyManager), ) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager),
RestorableBackupClickListener, SnapshotViewModel { RestorableBackupClickListener, SnapshotViewModel {
override val isRestoreOperation = true override val isRestoreOperation = true

View file

@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.RestorableBackup
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
@ -28,7 +29,7 @@ private val TAG = ApkRestore::class.java.simpleName
internal class ApkRestore( internal class ApkRestore(
private val context: Context, private val context: Context,
private val storagePlugin: StoragePlugin, private val pluginManager: StoragePluginManager,
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin, private val legacyStoragePlugin: LegacyStoragePlugin,
private val crypto: Crypto, private val crypto: Crypto,
@ -37,6 +38,7 @@ internal class ApkRestore(
) { ) {
private val pm = context.packageManager private val pm = context.packageManager
private val storagePlugin get() = pluginManager.appPlugin
fun restore(backup: RestorableBackup) = flow { fun restore(backup: RestorableBackup) = flow {
// we don't filter out apps without APK, so the user can manually install them // we don't filter out apps without APK, so the user can manually install them
@ -87,7 +89,7 @@ internal class ApkRestore(
emit(installResult) emit(installResult)
} }
@Suppress("ThrowsCount", "BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO @Suppress("ThrowsCount")
@Throws(IOException::class, SecurityException::class) @Throws(IOException::class, SecurityException::class)
private suspend fun restore( private suspend fun restore(
collector: FlowCollector<InstallResult>, collector: FlowCollector<InstallResult>,
@ -212,7 +214,6 @@ internal class ApkRestore(
* @return a [Pair] of the cached [File] and SHA-256 hash. * @return a [Pair] of the cached [File] and SHA-256 hash.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
private suspend fun cacheApk( private suspend fun cacheApk(
version: Byte, version: Byte,
token: Long, token: Long,

View file

@ -1,27 +0,0 @@
package com.stevesoltys.seedvault.settings
import android.content.ContentResolver
import android.provider.Settings
private val SETTING = Settings.Secure.BACKUP_MANAGER_CONSTANTS
object BackupManagerSettings {
/**
* This clears the backup settings, so that default values will be used.
*
* Before end of 2020 (Android 11) we changed the settings in an attempt
* to prevent automatic backups when flash drives are not plugged in.
* This turned out to not work reliably, so reset to defaults again here.
*
* We can remove this code after the last users can be expected
* to have changed storage at least once with this code deployed.
*/
fun resetDefaults(resolver: ContentResolver) {
if (Settings.Secure.getString(resolver, SETTING) != null) {
// setting this to null will cause the BackupManagerConstants to use default values
Settings.Secure.putString(resolver, SETTING, null)
}
}
}

View file

@ -10,6 +10,7 @@ import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.sharedViewModel import org.koin.androidx.viewmodel.ext.android.sharedViewModel
@ -18,6 +19,7 @@ class SchedulingFragment : PreferenceFragmentCompat(),
private val viewModel: SettingsViewModel by sharedViewModel() private val viewModel: SettingsViewModel by sharedViewModel()
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val storagePluginManager: StoragePluginManager by inject()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
permitDiskReads { permitDiskReads {
@ -29,7 +31,7 @@ class SchedulingFragment : PreferenceFragmentCompat(),
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val storage = settingsManager.getStorage() val storage = storagePluginManager.storageProperties
if (storage?.isUsb == true) { if (storage?.isUsb == true) {
findPreference<PreferenceCategory>("scheduling_category_conditions")?.isEnabled = false findPreference<PreferenceCategory>("scheduling_category_conditions")?.isEnabled = false
} }

View file

@ -22,6 +22,8 @@ import androidx.preference.TwoStatePreference
import androidx.work.WorkInfo import androidx.work.WorkInfo
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.StorageProperties
import com.stevesoltys.seedvault.restore.RestoreActivity import com.stevesoltys.seedvault.restore.RestoreActivity
import com.stevesoltys.seedvault.ui.toRelativeTime import com.stevesoltys.seedvault.ui.toRelativeTime
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@ -33,7 +35,7 @@ private val TAG = SettingsFragment::class.java.name
class SettingsFragment : PreferenceFragmentCompat() { class SettingsFragment : PreferenceFragmentCompat() {
private val viewModel: SettingsViewModel by sharedViewModel() private val viewModel: SettingsViewModel by sharedViewModel()
private val settingsManager: SettingsManager by inject() private val storagePluginManager: StoragePluginManager by inject()
private val backupManager: IBackupManager by inject() private val backupManager: IBackupManager by inject()
private lateinit var backup: TwoStatePreference private lateinit var backup: TwoStatePreference
@ -48,7 +50,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
private var menuBackupNow: MenuItem? = null private var menuBackupNow: MenuItem? = null
private var menuRestore: MenuItem? = null private var menuRestore: MenuItem? = null
private var storage: Storage? = null private val storageProperties: StorageProperties<*>?
get() = storagePluginManager.storageProperties
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
permitDiskReads { permitDiskReads {
@ -88,9 +91,15 @@ class SettingsFragment : PreferenceFragmentCompat() {
backupLocation = findPreference("backup_location")!! backupLocation = findPreference("backup_location")!!
backupLocation.setOnPreferenceClickListener { backupLocation.setOnPreferenceClickListener {
if (viewModel.isBackupRunning.value) {
// don't allow changing backup destination while backup is running
// TODO we could show toast or snackbar here
false
} else {
viewModel.chooseBackupLocation() viewModel.chooseBackupLocation()
true true
} }
}
autoRestore = findPreference(PREF_KEY_AUTO_RESTORE)!! autoRestore = findPreference(PREF_KEY_AUTO_RESTORE)!!
autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue -> autoRestore.onPreferenceChangeListener = OnPreferenceChangeListener { _, newValue ->
@ -148,7 +157,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
setAppBackupStatusSummary(time) setAppBackupStatusSummary(time)
} }
viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo -> viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo ->
viewModel.onWorkerStateChanged()
setAppBackupSchedulingSummary(workInfo) setAppBackupSchedulingSummary(workInfo)
} }
@ -164,7 +172,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
// we need to re-set the title when returning to this fragment // we need to re-set the title when returning to this fragment
activity?.setTitle(R.string.backup) activity?.setTitle(R.string.backup)
storage = settingsManager.getStorage()
setBackupEnabledState() setBackupEnabledState()
setBackupLocationSummary() setBackupLocationSummary()
setAutoRestoreState() setAutoRestoreState()
@ -241,7 +248,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
activity?.contentResolver?.let { activity?.contentResolver?.let {
autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1 autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1
} }
val storage = this.storage val storage = this.storageProperties
if (storage?.isUsb == true) { if (storage?.isUsb == true) {
autoRestore.summary = getString(R.string.settings_auto_restore_summary) + "\n\n" + autoRestore.summary = getString(R.string.settings_auto_restore_summary) + "\n\n" +
getString(R.string.settings_auto_restore_summary_usb, storage.name) getString(R.string.settings_auto_restore_summary_usb, storage.name)
@ -252,7 +259,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
private fun setBackupLocationSummary() { private fun setBackupLocationSummary() {
// get name of storage location // get name of storage location
backupLocation.summary = storage?.name ?: getString(R.string.settings_backup_location_none) backupLocation.summary =
storageProperties?.name ?: getString(R.string.settings_backup_location_none)
} }
private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) { private fun setAppBackupStatusSummary(lastBackupInMillis: Long?) {
@ -271,7 +279,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
* says that nothing is scheduled which can happen when backup destination is on flash drive. * says that nothing is scheduled which can happen when backup destination is on flash drive.
*/ */
private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) { private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) {
if (storage?.isUsb == true) { if (storageProperties?.isUsb == true) {
backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb) backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb)
return return
} }

View file

@ -3,15 +3,17 @@ package com.stevesoltys.seedvault.settings
import android.content.Context import android.content.Context
import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.hardware.usb.UsbDevice import android.hardware.usb.UsbDevice
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.stevesoltys.seedvault.getStorageContext
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.saf.DocumentsProviderStoragePlugin
import com.stevesoltys.seedvault.plugins.saf.SafStorage
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler.Companion.createWebDavProperties
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import java.util.concurrent.ConcurrentSkipListSet import java.util.concurrent.ConcurrentSkipListSet
@ -22,6 +24,13 @@ internal const val PREF_KEY_SCHED_FREQ = "scheduling_frequency"
internal const val PREF_KEY_SCHED_METERED = "scheduling_metered" internal const val PREF_KEY_SCHED_METERED = "scheduling_metered"
internal const val PREF_KEY_SCHED_CHARGING = "scheduling_charging" internal const val PREF_KEY_SCHED_CHARGING = "scheduling_charging"
private const val PREF_KEY_STORAGE_PLUGIN = "storagePlugin"
internal enum class StoragePluginType { // don't rename, will break existing installs
SAF,
WEB_DAV,
}
private const val PREF_KEY_STORAGE_URI = "storageUri" private const val PREF_KEY_STORAGE_URI = "storageUri"
private const val PREF_KEY_STORAGE_NAME = "storageName" private const val PREF_KEY_STORAGE_NAME = "storageName"
private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb" private const val PREF_KEY_STORAGE_IS_USB = "storageIsUsb"
@ -32,6 +41,10 @@ private const val PREF_KEY_FLASH_DRIVE_SERIAL_NUMBER = "flashSerialNumber"
private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId" private const val PREF_KEY_FLASH_DRIVE_VENDOR_ID = "flashDriveVendorId"
private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId" private const val PREF_KEY_FLASH_DRIVE_PRODUCT_ID = "flashDriveProductId"
private const val PREF_KEY_WEBDAV_URL = "webDavUrl"
private const val PREF_KEY_WEBDAV_USER = "webDavUser"
private const val PREF_KEY_WEBDAV_PASS = "webDavPass"
private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist" private const val PREF_KEY_BACKUP_APP_BLACKLIST = "backupAppBlacklist"
private const val PREF_KEY_BACKUP_STORAGE = "backup_storage" private const val PREF_KEY_BACKUP_STORAGE = "backup_storage"
@ -88,24 +101,55 @@ class SettingsManager(private val context: Context) {
token = newToken token = newToken
} }
// FIXME Storage is currently plugin specific and not generic internal val storagePluginType: StoragePluginType?
fun setStorage(storage: Storage) { get() {
val savedType = prefs.getString(PREF_KEY_STORAGE_PLUGIN, null)
return if (savedType == null) {
// check if this is an existing user that needs to be migrated
// this check could be removed after a reasonable migration time (added 2024)
if (prefs.getString(PREF_KEY_STORAGE_URI, null) != null) {
prefs.edit() prefs.edit()
.putString(PREF_KEY_STORAGE_URI, storage.uri.toString()) .putString(PREF_KEY_STORAGE_PLUGIN, StoragePluginType.SAF.name)
.putString(PREF_KEY_STORAGE_NAME, storage.name) .apply()
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb) StoragePluginType.SAF
.putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, storage.requiresNetwork) } else null
} else savedType.let {
try {
StoragePluginType.valueOf(it)
} catch (e: IllegalArgumentException) {
null
}
}
}
fun setStoragePlugin(plugin: StoragePlugin<*>) {
val value = when (plugin) {
is DocumentsProviderStoragePlugin -> StoragePluginType.SAF
is WebDavStoragePlugin -> StoragePluginType.WEB_DAV
else -> error("Unsupported plugin: ${plugin::class.java.simpleName}")
}.name
prefs.edit()
.putString(PREF_KEY_STORAGE_PLUGIN, value)
.apply() .apply()
} }
fun getStorage(): Storage? { fun setSafStorage(safStorage: SafStorage) {
prefs.edit()
.putString(PREF_KEY_STORAGE_URI, safStorage.uri.toString())
.putString(PREF_KEY_STORAGE_NAME, safStorage.name)
.putBoolean(PREF_KEY_STORAGE_IS_USB, safStorage.isUsb)
.putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, safStorage.requiresNetwork)
.apply()
}
fun getSafStorage(): SafStorage? {
val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null val uriStr = prefs.getString(PREF_KEY_STORAGE_URI, null) ?: return null
val uri = Uri.parse(uriStr) val uri = Uri.parse(uriStr)
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null) val name = prefs.getString(PREF_KEY_STORAGE_NAME, null)
?: throw IllegalStateException("no storage name") ?: throw IllegalStateException("no storage name")
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false) val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, false)
val requiresNetwork = prefs.getBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, false) val requiresNetwork = prefs.getBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, false)
return Storage(uri, name, isUsb, requiresNetwork) return SafStorage(uri, name, isUsb, requiresNetwork)
} }
fun setFlashDrive(usb: FlashDrive?) { fun setFlashDrive(usb: FlashDrive?) {
@ -134,20 +178,22 @@ class SettingsManager(private val context: Context) {
return FlashDrive(name, serialNumber, vendorId, productId) return FlashDrive(name, serialNumber, vendorId, productId)
} }
/** val webDavProperties: WebDavProperties?
* Check if we are able to do backups now by examining possible pre-conditions get() {
* such as plugged-in flash drive or internet access. val config = WebDavConfig(
* url = prefs.getString(PREF_KEY_WEBDAV_URL, null) ?: return null,
* Should be run off the UI thread (ideally I/O) because of disk access. username = prefs.getString(PREF_KEY_WEBDAV_USER, null) ?: return null,
* password = prefs.getString(PREF_KEY_WEBDAV_PASS, null) ?: return null,
* @return true if a backup is possible, false if not. )
*/ return createWebDavProperties(context, config)
@WorkerThread }
fun canDoBackupNow(): Boolean {
val storage = getStorage() ?: return false fun saveWebDavConfig(config: WebDavConfig) {
val systemContext = context.getStorageContext { storage.isUsb } prefs.edit()
return !storage.isUnavailableUsb(systemContext) && .putString(PREF_KEY_WEBDAV_URL, config.url)
!storage.isUnavailableNetwork(context, useMeteredNetwork) .putString(PREF_KEY_WEBDAV_USER, config.username)
.putString(PREF_KEY_WEBDAV_PASS, config.password)
.apply()
} }
fun backupApks(): Boolean { fun backupApks(): Boolean {
@ -195,42 +241,6 @@ class SettingsManager(private val context: Context) {
} }
} }
data class Storage(
val uri: Uri,
val name: String,
val isUsb: Boolean,
val requiresNetwork: Boolean,
) {
fun getDocumentFile(context: Context) = DocumentFile.fromTreeUri(context, uri)
?: throw AssertionError("Should only happen on API < 21.")
/**
* Returns true if this is USB storage that is not available, false otherwise.
*
* Must be run off UI thread (ideally I/O).
*/
@WorkerThread
fun isUnavailableUsb(context: Context): Boolean {
return isUsb && !getDocumentFile(context).isDirectory
}
/**
* Returns true if this is storage that requires network access,
* but it isn't available right now.
*/
fun isUnavailableNetwork(context: Context, allowMetered: Boolean): Boolean {
return requiresNetwork && !hasUnmeteredInternet(context, allowMetered)
}
private fun hasUnmeteredInternet(context: Context, allowMetered: Boolean): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val isMetered = cm.isActiveNetworkMetered
val capabilities = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
(allowMetered || !isMetered)
}
}
data class FlashDrive( data class FlashDrive(
val name: String, val name: String,
val serialNumber: String?, val serialNumber: String?,

View file

@ -28,12 +28,14 @@ import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.DiffUtil.calculateDiff import androidx.recyclerview.widget.DiffUtil.calculateDiff
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import com.stevesoltys.seedvault.BackupStateManager
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.permitDiskReads
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.SafStorage
import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.storage.StorageBackupJobService
import com.stevesoltys.seedvault.storage.StorageBackupService import com.stevesoltys.seedvault.storage.StorageBackupService
import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP import com.stevesoltys.seedvault.storage.StorageBackupService.Companion.EXTRA_START_APP_BACKUP
@ -44,6 +46,9 @@ import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.AppBackupWorker
import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME import com.stevesoltys.seedvault.worker.AppBackupWorker.Companion.UNIQUE_WORK_NAME
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
@ -59,12 +64,14 @@ internal class SettingsViewModel(
app: Application, app: Application,
settingsManager: SettingsManager, settingsManager: SettingsManager,
keyManager: KeyManager, keyManager: KeyManager,
private val pluginManager: StoragePluginManager,
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
private val appListRetriever: AppListRetriever, private val appListRetriever: AppListRetriever,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val backupInitializer: BackupInitializer, private val backupInitializer: BackupInitializer,
) : RequireProvisioningViewModel(app, settingsManager, keyManager) { backupStateManager: BackupStateManager,
) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager) {
private val contentResolver = app.contentResolver private val contentResolver = app.contentResolver
private val connectivityManager: ConnectivityManager? = private val connectivityManager: ConnectivityManager? =
@ -73,6 +80,7 @@ internal class SettingsViewModel(
override val isRestoreOperation = false override val isRestoreOperation = false
val isBackupRunning: StateFlow<Boolean>
private val mBackupPossible = MutableLiveData(false) private val mBackupPossible = MutableLiveData(false)
val backupPossible: LiveData<Boolean> = mBackupPossible val backupPossible: LiveData<Boolean> = mBackupPossible
@ -91,11 +99,11 @@ internal class SettingsViewModel(
private val mAppEditMode = MutableLiveData<Boolean>() private val mAppEditMode = MutableLiveData<Boolean>()
internal val appEditMode: LiveData<Boolean> = mAppEditMode internal val appEditMode: LiveData<Boolean> = mAppEditMode
private val _filesSummary = MutableLiveData<String>() private val mFilesSummary = MutableLiveData<String>()
internal val filesSummary: LiveData<String> = _filesSummary internal val filesSummary: LiveData<String> = mFilesSummary
private val _initEvent = MutableLiveEvent<Boolean>() private val mInitEvent = MutableLiveEvent<Boolean>()
val initEvent: LiveEvent<Boolean> = _initEvent val initEvent: LiveEvent<Boolean> = mInitEvent
private val storageObserver = object : ContentObserver(null) { private val storageObserver = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) { override fun onChange(selfChange: Boolean, uris: MutableCollection<Uri>, flags: Int) {
@ -122,18 +130,27 @@ internal class SettingsViewModel(
// this shouldn't cause disk reads, but it still does // this shouldn't cause disk reads, but it still does
viewModelScope viewModelScope
} }
isBackupRunning = backupStateManager.isBackupRunning.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
scope.launch { scope.launch {
// ensures the lastBackupTime LiveData gets set // ensures the lastBackupTime LiveData gets set
metadataManager.getLastBackupTime() metadataManager.getLastBackupTime()
// update running state
isBackupRunning.collect {
onBackupRunningStateChanged()
}
} }
onStoragePropertiesChanged() onStoragePropertiesChanged()
loadFilesSummary() loadFilesSummary()
} }
override fun onStorageLocationChanged() { override fun onStorageLocationChanged() {
val storage = settingsManager.getStorage() ?: return val storage = pluginManager.storageProperties ?: return
Log.i(TAG, "onStorageLocationChanged (isUsb: ${storage.isUsb}") Log.i(TAG, "onStorageLocationChanged (isUsb: ${storage.isUsb})")
if (storage.isUsb) { if (storage.isUsb) {
// disable storage backup if new storage is on USB // disable storage backup if new storage is on USB
cancelAppBackup() cancelAppBackup()
@ -147,33 +164,37 @@ internal class SettingsViewModel(
onStoragePropertiesChanged() onStoragePropertiesChanged()
} }
fun onWorkerStateChanged() { private fun onBackupRunningStateChanged() {
viewModelScope.launch(Dispatchers.IO) { if (isBackupRunning.value) mBackupPossible.postValue(false)
val canDo = settingsManager.canDoBackupNow() && else viewModelScope.launch(Dispatchers.IO) {
appBackupWorkInfo.value?.state != WorkInfo.State.RUNNING val canDo = !isBackupRunning.value && !pluginManager.isOnUnavailableUsb()
mBackupPossible.postValue(canDo) mBackupPossible.postValue(canDo)
} }
} }
private fun onStoragePropertiesChanged() { private fun onStoragePropertiesChanged() {
val storage = settingsManager.getStorage() ?: return val storage = pluginManager.storageProperties ?: return
Log.d(TAG, "onStoragePropertiesChanged") Log.d(TAG, "onStoragePropertiesChanged")
if (storage is SafStorage) {
// register storage observer // register storage observer
try { try {
contentResolver.unregisterContentObserver(storageObserver) contentResolver.unregisterContentObserver(storageObserver)
contentResolver.registerContentObserver(storage.uri, false, storageObserver) contentResolver.registerContentObserver(storage.uri, false, storageObserver)
} catch (e: SecurityException) { } catch (e: SecurityException) {
// This can happen if the app providing the storage was uninstalled. // This can happen if the app providing the storage was uninstalled.
// validLocationIsSet() gets called elsewhere and prompts for a new storage location. // validLocationIsSet() gets called elsewhere
// and prompts for a new storage location.
Log.e(TAG, "Error registering content observer for ${storage.uri}", e) Log.e(TAG, "Error registering content observer for ${storage.uri}", e)
} }
}
// register network observer if needed // register network observer if needed
if (networkCallback.registered && !storage.requiresNetwork) { if (networkCallback.registered && !storage.requiresNetwork) {
connectivityManager?.unregisterNetworkCallback(networkCallback) connectivityManager?.unregisterNetworkCallback(networkCallback)
networkCallback.registered = false networkCallback.registered = false
} else if (!networkCallback.registered && storage.requiresNetwork) { } else if (!networkCallback.registered && storage.requiresNetwork) {
// TODO we may want to warn the user when they start a backup on a metered connection
val request = NetworkRequest.Builder() val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build() .build()
@ -181,7 +202,7 @@ internal class SettingsViewModel(
networkCallback.registered = true networkCallback.registered = true
} }
// update whether we can do backups right now or not // update whether we can do backups right now or not
onWorkerStateChanged() onBackupRunningStateChanged()
} }
override fun onCleared() { override fun onCleared() {
@ -200,8 +221,7 @@ internal class SettingsViewModel(
i.putExtra(EXTRA_START_APP_BACKUP, true) i.putExtra(EXTRA_START_APP_BACKUP, true)
startForegroundService(app, i) startForegroundService(app, i)
} else { } else {
val isUsb = settingsManager.getStorage()?.isUsb ?: false AppBackupWorker.scheduleNow(app, reschedule = !pluginManager.isOnRemovableDrive)
AppBackupWorker.scheduleNow(app, reschedule = !isUsb)
} }
} }
} }
@ -232,7 +252,7 @@ internal class SettingsViewModel(
@UiThread @UiThread
fun loadFilesSummary() = viewModelScope.launch { fun loadFilesSummary() = viewModelScope.launch {
val uriSummary = storageBackup.getUriSummaryString() val uriSummary = storageBackup.getUriSummaryString()
_filesSummary.value = uriSummary.ifEmpty { mFilesSummary.value = uriSummary.ifEmpty {
app.getString(R.string.settings_backup_files_summary) app.getString(R.string.settings_backup_files_summary)
} }
} }
@ -248,10 +268,10 @@ internal class SettingsViewModel(
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
backupInitializer.initialize(onError) { backupInitializer.initialize(onError) {
_initEvent.postEvent(false) mInitEvent.postEvent(false)
scheduleAppBackup(CANCEL_AND_REENQUEUE) scheduleAppBackup(CANCEL_AND_REENQUEUE)
} }
_initEvent.postEvent(true) mInitEvent.postEvent(true)
} }
} }
// enable call log backups for existing installs (added end of 2020) // enable call log backups for existing installs (added end of 2020)
@ -280,20 +300,19 @@ internal class SettingsViewModel(
} }
fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) { fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) {
val storage = settingsManager.getStorage() ?: error("no storage available") if (!pluginManager.isOnRemovableDrive && backupManager.isBackupEnabled) {
if (!storage.isUsb && backupManager.isBackupEnabled) {
AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy) AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy)
} }
} }
fun scheduleFilesBackup() { fun scheduleFilesBackup() {
val storage = settingsManager.getStorage() ?: error("no storage available") if (!pluginManager.isOnRemovableDrive && settingsManager.isStorageBackupEnabled()) {
if (!storage.isUsb && settingsManager.isStorageBackupEnabled()) { val requiresNetwork = pluginManager.storageProperties?.requiresNetwork == true
BackupJobService.scheduleJob( BackupJobService.scheduleJob(
context = app, context = app,
jobServiceClass = StorageBackupJobService::class.java, jobServiceClass = StorageBackupJobService::class.java,
periodMillis = HOURS.toMillis(24), periodMillis = HOURS.toMillis(24),
networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED networkType = if (requiresNetwork) NETWORK_TYPE_UNMETERED
else NETWORK_TYPE_NONE, else NETWORK_TYPE_NONE,
deviceIdle = false, deviceIdle = false,
charging = true charging = true
@ -301,7 +320,7 @@ internal class SettingsViewModel(
} }
} }
fun cancelAppBackup() { private fun cancelAppBackup() {
AppBackupWorker.unschedule(app) AppBackupWorker.unschedule(app)
} }

View file

@ -8,7 +8,7 @@ import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
import javax.crypto.SecretKey import javax.crypto.SecretKey
internal class SeedvaultStoragePlugin( internal class SeedvaultSafStoragePlugin(
private val appContext: Context, private val appContext: Context,
private val storage: DocumentsStorage, private val storage: DocumentsStorage,
private val keyManager: KeyManager, private val keyManager: KeyManager,
@ -16,12 +16,8 @@ internal class SeedvaultStoragePlugin(
/** /**
* Attention: This context might be from a different user. Use with care. * Attention: This context might be from a different user. Use with care.
*/ */
override val context: Context override val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
get() = appContext.getStorageContext { override val root: DocumentFile get() = storage.rootBackupDir ?: error("No storage set")
storage.storage?.isUsb == true
}
override val root: DocumentFile
get() = storage.rootBackupDir ?: error("No storage set")
override fun getMasterKey(): SecretKey = keyManager.getMainKey() override fun getMasterKey(): SecretKey = keyManager.getMainKey()
override fun hasMasterKey(): Boolean = keyManager.hasMainKey() override fun hasMasterKey(): Boolean = keyManager.hasMainKey()

View file

@ -1,8 +1,10 @@
package com.stevesoltys.seedvault.storage package com.stevesoltys.seedvault.storage
import android.content.Intent import android.content.Intent
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.worker.AppBackupWorker import com.stevesoltys.seedvault.worker.AppBackupWorker
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.calyxos.backup.storage.api.BackupObserver import org.calyxos.backup.storage.api.BackupObserver
import org.calyxos.backup.storage.api.RestoreObserver import org.calyxos.backup.storage.api.RestoreObserver
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
@ -31,19 +33,31 @@ internal class StorageBackupService : BackupService() {
companion object { companion object {
internal const val EXTRA_START_APP_BACKUP = "startAppBackup" internal const val EXTRA_START_APP_BACKUP = "startAppBackup"
private val mIsRunning = MutableStateFlow(false)
val isRunning = mIsRunning.asStateFlow()
} }
override val storageBackup: StorageBackup by inject() override val storageBackup: StorageBackup by inject()
private val settingsManager: SettingsManager by inject() private val storagePluginManager: StoragePluginManager by inject()
// use lazy delegate because context isn't available during construction time // use lazy delegate because context isn't available during construction time
override val backupObserver: BackupObserver by lazy { override val backupObserver: BackupObserver by lazy {
NotificationBackupObserver(applicationContext) NotificationBackupObserver(applicationContext)
} }
override fun onCreate() {
super.onCreate()
mIsRunning.value = true
}
override fun onDestroy() {
super.onDestroy()
mIsRunning.value = false
}
override fun onBackupFinished(intent: Intent, success: Boolean) { override fun onBackupFinished(intent: Intent, success: Boolean) {
if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) { if (intent.getBooleanExtra(EXTRA_START_APP_BACKUP, false)) {
val isUsb = settingsManager.getStorage()?.isUsb ?: false val isUsb = storagePluginManager.storageProperties?.isUsb ?: false
AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb) AppBackupWorker.scheduleNow(applicationContext, reschedule = !isUsb)
} }
} }

View file

@ -1,10 +1,9 @@
package com.stevesoltys.seedvault.storage package com.stevesoltys.seedvault.storage
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
import org.calyxos.backup.storage.api.StoragePlugin
import org.koin.dsl.module import org.koin.dsl.module
val storageModule = module { val storageModule = module {
single<StoragePlugin> { SeedvaultStoragePlugin(get(), get(), get()) } single { StorageBackup(get(), { get<StoragePluginManager>().filesPlugin }) }
single { StorageBackup(get(), get()) }
} }

View file

@ -0,0 +1,291 @@
package com.stevesoltys.seedvault.storage
import android.util.Log
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Response.HrefRelation.SELF
import at.bitfire.dav4jvm.exception.NotFoundException
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.ResourceType
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.plugins.chunkFolderRegex
import com.stevesoltys.seedvault.plugins.webdav.DIRECTORY_ROOT
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
import com.stevesoltys.seedvault.plugins.webdav.WebDavStorage
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.calyxos.backup.storage.api.StoragePlugin
import org.calyxos.backup.storage.api.StoredSnapshot
import org.calyxos.backup.storage.plugin.PluginConstants.SNAPSHOT_EXT
import org.calyxos.backup.storage.plugin.PluginConstants.chunkRegex
import org.calyxos.backup.storage.plugin.PluginConstants.snapshotRegex
import org.koin.core.time.measureDuration
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import javax.crypto.SecretKey
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
internal class WebDavStoragePlugin(
private val keyManager: KeyManager,
/**
* The result of Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
*/
androidId: String,
webDavConfig: WebDavConfig,
root: String = DIRECTORY_ROOT,
) : WebDavStorage(webDavConfig, root), StoragePlugin {
/**
* The folder name is our user ID plus .sv extension (for SeedVault).
* The user or `androidId` 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.
*/
private val folder: String = "$androidId.sv"
@Throws(IOException::class)
override suspend fun init() {
val location = url.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
davCollection.head { response ->
debugLog { "Root exists: $response" }
}
} catch (e: NotFoundException) {
val response = davCollection.createFolder()
debugLog { "init() = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
@Throws(IOException::class)
override suspend fun getAvailableChunkIds(): List<String> {
val location = "$url/$folder".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getAvailableChunkIds($location)" }
val expectedChunkFolders = (0x00..0xff).map {
Integer.toHexString(it).padStart(2, '0')
}.toHashSet()
val chunkIds = ArrayList<String>()
try {
val duration = measureDuration {
davCollection.propfind(
depth = 2,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getAvailableChunkIds() = $response" }
// This callback will be called for every file in the folder
if (relation != SELF && response.isFolder()) {
val name = response.hrefName()
if (chunkFolderRegex.matches(name)) {
expectedChunkFolders.remove(name)
}
} else if (relation != SELF && response.href.pathSize >= 2) {
val folderName =
response.href.pathSegments[response.href.pathSegments.size - 2]
if (folderName != folder && chunkFolderRegex.matches(folderName)) {
val name = response.hrefName()
if (chunkRegex.matches(name)) chunkIds.add(name)
}
}
}
}
Log.i(TAG, "Retrieving chunks took $duration")
} catch (e: NotFoundException) {
debugLog { "Folder not found: $location" }
davCollection.createFolder()
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error populating chunk folders: ", e)
}
Log.i(TAG, "Got ${chunkIds.size} available chunks")
createMissingChunkFolders(expectedChunkFolders)
return chunkIds
}
@Throws(IOException::class)
private suspend fun createMissingChunkFolders(
missingChunkFolders: Set<String>,
) {
val s = missingChunkFolders.size
for ((i, chunkFolderName) in missingChunkFolders.withIndex()) {
val location = "$url/$folder/$chunkFolderName".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
val response = davCollection.createFolder()
debugLog { "Created missing folder $chunkFolderName (${i + 1}/$s) $response" }
}
}
override fun getMasterKey(): SecretKey = keyManager.getMainKey()
override fun hasMasterKey(): Boolean = keyManager.hasMainKey()
@Throws(IOException::class)
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
val chunkFolderName = chunkId.substring(0, 2)
val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl()
debugLog { "getChunkOutputStream($location) for $chunkId" }
return try {
getOutputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting OutputStream for $chunkId: ", e)
}
}
@Throws(IOException::class)
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
val location = "$url/$folder/$timestamp$SNAPSHOT_EXT".toHttpUrl()
debugLog { "getBackupSnapshotOutputStream($location)" }
return try {
getOutputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting OutputStream for $timestamp$SNAPSHOT_EXT: ", e)
}
}
/************************* Restore *******************************/
@Throws(IOException::class)
override suspend fun getBackupSnapshotsForRestore(): List<StoredSnapshot> {
val location = url.toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getBackupSnapshotsForRestore($location)" }
val snapshots = ArrayList<StoredSnapshot>()
try {
davCollection.propfind(
depth = 2,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getBackupSnapshotsForRestore() = $response" }
// This callback will be called for every file in the folder
if (relation != SELF && !response.isFolder()) {
val name = response.hrefName()
val match = snapshotRegex.matchEntire(name)
if (match != null) {
val timestamp = match.groupValues[1].toLong()
val folderName =
response.href.pathSegments[response.href.pathSegments.size - 2]
val storedSnapshot = StoredSnapshot(folderName, timestamp)
snapshots.add(storedSnapshot)
}
}
}
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting snapshots for restore: ", e)
}
return snapshots
}
@Throws(IOException::class)
override suspend fun getBackupSnapshotInputStream(storedSnapshot: StoredSnapshot): InputStream {
val timestamp = storedSnapshot.timestamp
val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl()
debugLog { "getBackupSnapshotInputStream($location)" }
return try {
getInputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting InputStream for $storedSnapshot: ", e)
}
}
@Throws(IOException::class)
override suspend fun getChunkInputStream(
snapshot: StoredSnapshot,
chunkId: String,
): InputStream {
val chunkFolderName = chunkId.substring(0, 2)
val location = "$url/${snapshot.userId}/$chunkFolderName/$chunkId".toHttpUrl()
debugLog { "getChunkInputStream($location) for $chunkId" }
return try {
getInputStream(location)
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting InputStream for $chunkFolderName/$chunkId: ", e)
}
}
/************************* Pruning *******************************/
@Throws(IOException::class)
override suspend fun getCurrentBackupSnapshots(): List<StoredSnapshot> {
val location = "$url/$folder".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
debugLog { "getCurrentBackupSnapshots($location)" }
val snapshots = ArrayList<StoredSnapshot>()
try {
val duration = measureDuration {
davCollection.propfind(
depth = 1,
reqProp = arrayOf(DisplayName.NAME, ResourceType.NAME),
) { response, relation ->
debugLog { "getCurrentBackupSnapshots() = $response" }
// This callback will be called for every file in the folder
if (relation != SELF && !response.isFolder()) {
val match = snapshotRegex.matchEntire(response.hrefName())
if (match != null) {
val timestamp = match.groupValues[1].toLong()
val storedSnapshot = StoredSnapshot(folder, timestamp)
snapshots.add(storedSnapshot)
}
}
}
}
Log.i(TAG, "getCurrentBackupSnapshots took $duration")
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException("Error getting current snapshots: ", e)
}
Log.i(TAG, "Got ${snapshots.size} snapshots.")
return snapshots
}
@Throws(IOException::class)
override suspend fun deleteBackupSnapshot(storedSnapshot: StoredSnapshot) {
val timestamp = storedSnapshot.timestamp
Log.d(TAG, "Deleting snapshot $timestamp")
val location = "$url/${storedSnapshot.userId}/$timestamp$SNAPSHOT_EXT".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
val response = suspendCoroutine { cont ->
davCollection.delete { response ->
cont.resume(response)
}
}
debugLog { "deleteBackupSnapshot() = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
@Throws(IOException::class)
override suspend fun deleteChunks(chunkIds: List<String>) {
chunkIds.forEach { chunkId ->
val chunkFolderName = chunkId.substring(0, 2)
val location = "$url/$folder/$chunkFolderName/$chunkId".toHttpUrl()
val davCollection = DavCollection(okHttpClient, location)
try {
val response = suspendCoroutine { cont ->
davCollection.delete { response ->
cont.resume(response)
}
}
debugLog { "deleteChunks($chunkId) = $response" }
} catch (e: Exception) {
if (e is IOException) throw e
else throw IOException(e)
}
}
}
}

View file

@ -7,6 +7,8 @@ import android.os.IBinder
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -18,6 +20,11 @@ private val TAG = ConfigurableBackupTransportService::class.java.simpleName
*/ */
class ConfigurableBackupTransportService : Service(), KoinComponent { class ConfigurableBackupTransportService : Service(), KoinComponent {
companion object {
private val mIsRunning = MutableStateFlow(false)
val isRunning = mIsRunning.asStateFlow()
}
private var transport: ConfigurableBackupTransport? = null private var transport: ConfigurableBackupTransport? = null
private val keyManager: KeyManager by inject() private val keyManager: KeyManager by inject()
@ -27,6 +34,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
transport = ConfigurableBackupTransport(applicationContext) transport = ConfigurableBackupTransport(applicationContext)
mIsRunning.value = true
Log.d(TAG, "Service created.") Log.d(TAG, "Service created.")
} }
@ -47,6 +55,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
super.onDestroy() super.onDestroy()
notificationManager.onServiceDestroyed() notificationManager.onServiceDestroyed()
transport = null transport = null
mIsRunning.value = false
Log.d(TAG, "Service destroyed.") Log.d(TAG, "Service destroyed.")
} }

View file

@ -25,6 +25,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
@ -54,11 +55,10 @@ private class CoordinatorState(
* @author Steve Soltys * @author Steve Soltys
* @author Torsten Grote * @author Torsten Grote
*/ */
@WorkerThread // entire class should always be accessed from a worker thread, so blocking is ok @WorkerThread
@Suppress("BlockingMethodInNonBlockingContext")
internal class BackupCoordinator( internal class BackupCoordinator(
private val context: Context, private val context: Context,
private val plugin: StoragePlugin, private val pluginManager: StoragePluginManager,
private val kv: KVBackup, private val kv: KVBackup,
private val full: FullBackup, private val full: FullBackup,
private val clock: Clock, private val clock: Clock,
@ -68,6 +68,7 @@ internal class BackupCoordinator(
private val nm: BackupNotificationManager, private val nm: BackupNotificationManager,
) { ) {
private val plugin get() = pluginManager.appPlugin
private val state = CoordinatorState( private val state = CoordinatorState(
calledInitialize = false, calledInitialize = false,
calledClearBackupData = false, calledClearBackupData = false,
@ -126,7 +127,7 @@ internal class BackupCoordinator(
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error initializing device", e) Log.e(TAG, "Error initializing device", e)
// Show error notification if we needed init or were ready for backups // Show error notification if we needed init or were ready for backups
if (metadataManager.requiresInit || settingsManager.canDoBackupNow()) nm.onBackupError() if (metadataManager.requiresInit || pluginManager.canDoBackupNow()) nm.onBackupError()
TRANSPORT_ERROR TRANSPORT_ERROR
} }
@ -354,7 +355,7 @@ internal class BackupCoordinator(
if (result == TRANSPORT_OK) { if (result == TRANSPORT_OK) {
val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER
// call onPackageBackedUp for @pm@ only if we can do backups right now // call onPackageBackedUp for @pm@ only if we can do backups right now
if (isNormalBackup || settingsManager.canDoBackupNow()) { if (isNormalBackup || pluginManager.canDoBackupNow()) {
try { try {
onPackageBackedUp(packageInfo, BackupType.KV, size) onPackageBackedUp(packageInfo, BackupType.KV, size)
} catch (e: Exception) { } catch (e: Exception) {
@ -411,7 +412,7 @@ internal class BackupCoordinator(
val longBackoff = DAYS.toMillis(30) val longBackoff = DAYS.toMillis(30)
// back off if there's no storage set // back off if there's no storage set
val storage = settingsManager.getStorage() ?: return longBackoff val storage = pluginManager.storageProperties ?: return longBackoff
return when { return when {
// back off if storage is removable and not available right now // back off if storage is removable and not available right now
storage.isUnavailableUsb(context) -> longBackoff storage.isUnavailableUsb(context) -> longBackoff
@ -425,7 +426,9 @@ internal class BackupCoordinator(
} }
} }
private suspend fun StoragePlugin.getMetadataOutputStream(token: Long? = null): OutputStream { private suspend fun StoragePlugin<*>.getMetadataOutputStream(
token: Long? = null,
): OutputStream {
val t = token ?: settingsManager.getToken() ?: throw IOException("no current token") val t = token ?: settingsManager.getToken() ?: throw IOException("no current token")
return getOutputStream(t, FILE_BACKUP_METADATA) return getOutputStream(t, FILE_BACKUP_METADATA)
} }

View file

@ -11,38 +11,38 @@ val backupModule = module {
context = androidContext(), context = androidContext(),
backupManager = get(), backupManager = get(),
settingsManager = get(), settingsManager = get(),
plugin = get() pluginManager = get(),
) )
} }
single<KvDbManager> { KvDbManagerImpl(androidContext()) } single<KvDbManager> { KvDbManagerImpl(androidContext()) }
single { single {
KVBackup( KVBackup(
plugin = get(), pluginManager = get(),
settingsManager = get(), settingsManager = get(),
inputFactory = get(), inputFactory = get(),
crypto = get(), crypto = get(),
dbManager = get() dbManager = get(),
) )
} }
single { single {
FullBackup( FullBackup(
plugin = get(), pluginManager = get(),
settingsManager = get(), settingsManager = get(),
inputFactory = get(), inputFactory = get(),
crypto = get() crypto = get(),
) )
} }
single { single {
BackupCoordinator( BackupCoordinator(
context = androidContext(), context = androidContext(),
plugin = get(), pluginManager = get(),
kv = get(), kv = get(),
full = get(), full = get(),
clock = get(), clock = get(),
packageService = get(), packageService = get(),
metadataManager = get(), metadataManager = get(),
settingsManager = get(), settingsManager = get(),
nm = get() nm = get(),
) )
} }
} }

View file

@ -11,7 +11,7 @@ import android.util.Log
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForFull import com.stevesoltys.seedvault.header.getADForFull
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import libcore.io.IoUtils.closeQuietly import libcore.io.IoUtils.closeQuietly
import java.io.EOFException import java.io.EOFException
@ -39,12 +39,13 @@ private val TAG = FullBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
internal class FullBackup( internal class FullBackup(
private val plugin: StoragePlugin, private val pluginManager: StoragePluginManager,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val inputFactory: InputFactory, private val inputFactory: InputFactory,
private val crypto: Crypto, private val crypto: Crypto,
) { ) {
private val plugin get() = pluginManager.appPlugin
private var state: FullBackupState? = null private var state: FullBackupState? = null
fun hasState() = state != null fun hasState() = state != null

View file

@ -13,7 +13,7 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.header.getADForKV
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import java.io.IOException import java.io.IOException
import java.util.zip.GZIPOutputStream import java.util.zip.GZIPOutputStream
@ -31,15 +31,15 @@ const val DEFAULT_QUOTA_KEY_VALUE_BACKUP = (2 * (5 * 1024 * 1024)).toLong()
private val TAG = KVBackup::class.java.simpleName private val TAG = KVBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackup( internal class KVBackup(
private val plugin: StoragePlugin, private val pluginManager: StoragePluginManager,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val inputFactory: InputFactory, private val inputFactory: InputFactory,
private val crypto: Crypto, private val crypto: Crypto,
private val dbManager: KvDbManager, private val dbManager: KvDbManager,
) { ) {
private val plugin get() = pluginManager.appPlugin
private var state: KVBackupState? = null private var state: KVBackupState? = null
fun hasState() = state != null fun hasState() = state != null
@ -138,7 +138,7 @@ internal class KVBackup(
// K/V backups (typically starting with package manager metadata - @pm@) // K/V backups (typically starting with package manager metadata - @pm@)
// are scheduled with JobInfo.Builder#setOverrideDeadline() // are scheduled with JobInfo.Builder#setOverrideDeadline()
// and thus do not respect backoff. // and thus do not respect backoff.
settingsManager.canDoBackupNow() pluginManager.canDoBackupNow()
} else { } else {
// all other packages always need upload // all other packages always need upload
true true

View file

@ -18,6 +18,7 @@ import android.util.Log.INFO
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
private val TAG = PackageService::class.java.simpleName private val TAG = PackageService::class.java.simpleName
@ -32,11 +33,12 @@ internal class PackageService(
private val context: Context, private val context: Context,
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val plugin: StoragePlugin, private val pluginManager: StoragePluginManager,
) { ) {
private val packageManager: PackageManager = context.packageManager private val packageManager: PackageManager = context.packageManager
private val myUserId = UserHandle.myUserId() private val myUserId = UserHandle.myUserId()
private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin
val eligiblePackages: List<String> val eligiblePackages: List<String>
@WorkerThread @WorkerThread

View file

@ -13,7 +13,7 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
import com.stevesoltys.seedvault.header.UnsupportedVersionException import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.getADForFull import com.stevesoltys.seedvault.header.getADForFull
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePluginManager
import libcore.io.IoUtils.closeQuietly import libcore.io.IoUtils.closeQuietly
import java.io.EOFException import java.io.EOFException
import java.io.IOException import java.io.IOException
@ -32,9 +32,8 @@ private class FullRestoreState(
private val TAG = FullRestore::class.java.simpleName private val TAG = FullRestore::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullRestore( internal class FullRestore(
private val plugin: StoragePlugin, private val pluginManager: StoragePluginManager,
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyPlugin: LegacyStoragePlugin, private val legacyPlugin: LegacyStoragePlugin,
private val outputFactory: OutputFactory, private val outputFactory: OutputFactory,
@ -42,6 +41,7 @@ internal class FullRestore(
private val crypto: Crypto, private val crypto: Crypto,
) { ) {
private val plugin get() = pluginManager.appPlugin
private var state: FullRestoreState? = null private var state: FullRestoreState? = null
fun hasState() = state != null fun hasState() = state != null

View file

@ -16,13 +16,12 @@ import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.header.getADForKV
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.transport.backup.KVDb import com.stevesoltys.seedvault.transport.backup.KVDb
import com.stevesoltys.seedvault.transport.backup.KvDbManager import com.stevesoltys.seedvault.transport.backup.KvDbManager
import libcore.io.IoUtils.closeQuietly import libcore.io.IoUtils.closeQuietly
import java.io.IOException import java.io.IOException
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.util.ArrayList
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import javax.crypto.AEADBadTagException import javax.crypto.AEADBadTagException
@ -39,9 +38,8 @@ private class KVRestoreState(
private val TAG = KVRestore::class.java.simpleName private val TAG = KVRestore::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVRestore( internal class KVRestore(
private val plugin: StoragePlugin, private val pluginManager: StoragePluginManager,
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyPlugin: LegacyStoragePlugin, private val legacyPlugin: LegacyStoragePlugin,
private val outputFactory: OutputFactory, private val outputFactory: OutputFactory,
@ -50,6 +48,7 @@ internal class KVRestore(
private val dbManager: KvDbManager, private val dbManager: KvDbManager,
) { ) {
private val plugin get() = pluginManager.appPlugin
private var state: KVRestoreState? = null private var state: KVRestoreState? = null
/** /**

View file

@ -21,6 +21,7 @@ import com.stevesoltys.seedvault.metadata.DecryptionFailedException
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.MetadataReader import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS import com.stevesoltys.seedvault.transport.D2D_TRANSPORT_FLAGS
import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS import com.stevesoltys.seedvault.transport.DEFAULT_TRANSPORT_FLAGS
@ -49,19 +50,19 @@ private data class RestoreCoordinatorState(
private val TAG = RestoreCoordinator::class.java.simpleName private val TAG = RestoreCoordinator::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class RestoreCoordinator( internal class RestoreCoordinator(
private val context: Context, private val context: Context,
private val crypto: Crypto, private val crypto: Crypto,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
private val notificationManager: BackupNotificationManager, private val notificationManager: BackupNotificationManager,
private val plugin: StoragePlugin, private val pluginManager: StoragePluginManager,
private val kv: KVRestore, private val kv: KVRestore,
private val full: FullRestore, private val full: FullRestore,
private val metadataReader: MetadataReader, private val metadataReader: MetadataReader,
) { ) {
private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin
private var state: RestoreCoordinatorState? = null private var state: RestoreCoordinatorState? = null
private var backupMetadata: BackupMetadata? = null private var backupMetadata: BackupMetadata? = null
private val failedPackages = ArrayList<String>() private val failedPackages = ArrayList<String>()
@ -169,7 +170,7 @@ internal class RestoreCoordinator(
// check if we even have a backup of that app // check if we even have a backup of that app
if (metadataManager.getPackageMetadata(pmPackageName) != null) { if (metadataManager.getPackageMetadata(pmPackageName) != null) {
// remind user to plug in storage device // remind user to plug in storage device
val storageName = settingsManager.getStorage()?.name val storageName = pluginManager.storageProperties?.name
?: context.getString(R.string.settings_backup_location_none) ?: context.getString(R.string.settings_backup_location_none)
notificationManager.onRemovableStorageNotAvailableForRestore( notificationManager.onRemovableStorageNotAvailableForRestore(
pmPackageName, pmPackageName,
@ -363,9 +364,8 @@ internal class RestoreCoordinator(
fun isFailedPackage(packageName: String) = packageName in failedPackages fun isFailedPackage(packageName: String) = packageName in failedPackages
// TODO this is plugin specific, needs to be factored out when supporting different plugins
private fun isStorageRemovableAndNotAvailable(): Boolean { private fun isStorageRemovableAndNotAvailable(): Boolean {
val storage = settingsManager.getStorage() ?: return false val storage = pluginManager.storageProperties ?: return false
return storage.isUnavailableUsb(context) return storage.isUnavailableUsb(context)
} }

View file

@ -3,13 +3,14 @@ package com.stevesoltys.seedvault.ui
import android.app.Application import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.ui.storage.StorageViewModel
abstract class RequireProvisioningViewModel( abstract class RequireProvisioningViewModel(
protected val app: Application, protected val app: Application,
protected val settingsManager: SettingsManager, protected val settingsManager: SettingsManager,
protected val keyManager: KeyManager, protected val keyManager: KeyManager,
private val pluginManager: StoragePluginManager,
) : AndroidViewModel(app) { ) : AndroidViewModel(app) {
abstract val isRestoreOperation: Boolean abstract val isRestoreOperation: Boolean
@ -18,7 +19,7 @@ abstract class RequireProvisioningViewModel(
internal val chooseBackupLocation: LiveEvent<Boolean> get() = mChooseBackupLocation internal val chooseBackupLocation: LiveEvent<Boolean> get() = mChooseBackupLocation
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true) internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
internal fun validLocationIsSet() = StorageViewModel.validLocationIsSet(app, settingsManager) internal fun validLocationIsSet() = pluginManager.isValidAppPluginSet()
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey() internal fun recoveryCodeIsSet() = keyManager.hasBackupKey()

View file

@ -103,8 +103,7 @@ internal class RecoveryCodeViewModel(
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify? // TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
// remove old storage snapshots and clear cache // remove old storage snapshots and clear cache
storageBackup.deleteAllSnapshots() storageBackup.init()
storageBackup.clearCache()
try { try {
// initialize the new location // initialize the new location
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) { if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {

View file

@ -3,11 +3,16 @@ package com.stevesoltys.seedvault.ui.storage
import android.app.Application import android.app.Application
import android.app.backup.IBackupManager import android.app.backup.IBackupManager
import android.app.job.JobInfo import android.app.job.JobInfo
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.SafHandler
import com.stevesoltys.seedvault.plugins.saf.SafStorage
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.storage.StorageBackupJobService import com.stevesoltys.seedvault.storage.StorageBackupJobService
import com.stevesoltys.seedvault.transport.backup.BackupInitializer import com.stevesoltys.seedvault.transport.backup.BackupInitializer
@ -26,14 +31,18 @@ internal class BackupStorageViewModel(
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val backupInitializer: BackupInitializer, private val backupInitializer: BackupInitializer,
private val storageBackup: StorageBackup, private val storageBackup: StorageBackup,
safHandler: SafHandler,
webDavHandler: WebDavHandler,
settingsManager: SettingsManager, settingsManager: SettingsManager,
) : StorageViewModel(app, settingsManager) { storagePluginManager: StoragePluginManager,
) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) {
override val isRestoreOperation = false override val isRestoreOperation = false
override fun onLocationSet(uri: Uri) { override fun onSafUriSet(safStorage: SafStorage) {
val isUsb = saveStorage(uri) safHandler.save(safStorage)
if (isUsb) { safHandler.setPlugin(safStorage)
if (safStorage.isUsb) {
// disable storage backup if new storage is on USB // disable storage backup if new storage is on USB
cancelBackupWorkers() cancelBackupWorkers()
} else { } else {
@ -41,13 +50,23 @@ internal class BackupStorageViewModel(
// also to update the network requirement of the new storage // also to update the network requirement of the new storage
scheduleBackupWorkers() scheduleBackupWorkers()
} }
onStorageLocationSet(safStorage.isUsb)
}
override fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
webdavHandler.save(properties)
webdavHandler.setPlugin(properties, plugin)
scheduleBackupWorkers()
onStorageLocationSet(isUsb = false)
}
private fun onStorageLocationSet(isUsb: Boolean) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
// remove old storage snapshots and clear cache
// TODO is this needed? It also does create all 255 chunk folders which takes time
// pass a flag to getCurrentBackupSnapshots() to not create missing folders?
storageBackup.deleteAllSnapshots()
storageBackup.clearCache()
try { try {
// remove old storage snapshots and clear cache
// TODO For SAF, this also does create all 255 chunk folders which takes time
// pass a flag to getCurrentBackupSnapshots() to not create missing folders?
storageBackup.init()
// initialize the new location (if backups are enabled) // initialize the new location (if backups are enabled)
if (backupManager.isBackupEnabled) { if (backupManager.isBackupEnabled) {
val onError = { val onError = {
@ -75,7 +94,7 @@ internal class BackupStorageViewModel(
} }
private fun scheduleBackupWorkers() { private fun scheduleBackupWorkers() {
val storage = settingsManager.getStorage() ?: error("no storage available") val storage = storagePluginManager.storageProperties ?: error("no storage available")
if (!storage.isUsb) { if (!storage.isUsb) {
if (backupManager.isBackupEnabled) { if (backupManager.isBackupEnabled) {
AppBackupWorker.schedule(app, settingsManager, CANCEL_AND_REENQUEUE) AppBackupWorker.schedule(app, settingsManager, CANCEL_AND_REENQUEUE)

View file

@ -1,12 +1,16 @@
package com.stevesoltys.seedvault.ui.storage package com.stevesoltys.seedvault.ui.storage
import android.app.Application import android.app.Application
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT import com.stevesoltys.seedvault.plugins.saf.DIRECTORY_ROOT
import com.stevesoltys.seedvault.plugins.saf.SafHandler
import com.stevesoltys.seedvault.plugins.saf.SafStorage
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -16,26 +20,28 @@ private val TAG = RestoreStorageViewModel::class.java.simpleName
internal class RestoreStorageViewModel( internal class RestoreStorageViewModel(
private val app: Application, private val app: Application,
private val storagePlugin: StoragePlugin, safHandler: SafHandler,
webDavHandler: WebDavHandler,
settingsManager: SettingsManager, settingsManager: SettingsManager,
) : StorageViewModel(app, settingsManager) { storagePluginManager: StoragePluginManager,
) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) {
override val isRestoreOperation = true override val isRestoreOperation = true
override fun onLocationSet(uri: Uri) { override fun onSafUriSet(safStorage: SafStorage) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val storage = createStorage(uri)
val hasBackup = try { val hasBackup = try {
storagePlugin.hasBackup(storage) safHandler.hasAppBackup(safStorage)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error reading URI: $uri", e) Log.e(TAG, "Error reading URI: ${safStorage.uri}", e)
false false
} }
if (hasBackup) { if (hasBackup) {
saveStorage(storage) safHandler.save(safStorage)
safHandler.setPlugin(safStorage)
mLocationChecked.postEvent(LocationResult()) mLocationChecked.postEvent(LocationResult())
} else { } else {
Log.w(TAG, "Location was rejected: $uri") Log.w(TAG, "Location was rejected: ${safStorage.uri}")
// notify the UI that the location was invalid // notify the UI that the location was invalid
val errorMsg = val errorMsg =
@ -45,4 +51,26 @@ internal class RestoreStorageViewModel(
} }
} }
override fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
viewModelScope.launch(Dispatchers.IO) {
val hasBackup = try {
webdavHandler.hasAppBackup(plugin)
} catch (e: IOException) {
Log.e(TAG, "Error reading: ${properties.config.url}", e)
false
}
if (hasBackup) {
webdavHandler.save(properties)
webdavHandler.setPlugin(properties, plugin)
mLocationChecked.postEvent(LocationResult())
} else {
Log.w(TAG, "Location was rejected: ${properties.config.url}")
// notify the UI that the location was invalid
val errorMsg =
app.getString(R.string.restore_invalid_location_message, DIRECTORY_ROOT)
mLocationChecked.postEvent(LocationResult(errorMsg))
}
}
}
} }

View file

@ -9,11 +9,11 @@ import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.content.pm.PackageManager.PERMISSION_GRANTED import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver
import com.stevesoltys.seedvault.ui.BackupActivity import com.stevesoltys.seedvault.ui.BackupActivity
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_SETUP_WIZARD import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_SETUP_WIZARD
@ -82,14 +82,6 @@ class StorageActivity : BackupActivity() {
} }
} }
override fun onBackPressed() {
if (supportFragmentManager.backStackEntryCount > 0) {
Log.d(TAG, "Blocking back button.")
} else {
super.onBackPressed()
}
}
private fun onInvalidLocation(errorMsg: String) { private fun onInvalidLocation(errorMsg: String) {
if (viewModel.isRestoreOperation) { if (viewModel.isRestoreOperation) {
val dialog = AlertDialog.Builder(this) val dialog = AlertDialog.Builder(this)

View file

@ -1,6 +1,7 @@
package com.stevesoltys.seedvault.ui.storage package com.stevesoltys.seedvault.ui.storage
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.GONE import android.view.View.GONE
@ -10,6 +11,7 @@ import android.view.ViewGroup
import android.widget.Button import android.widget.Button
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.activity.addCallback
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
@ -34,6 +36,14 @@ class StorageCheckFragment : Fragment() {
} }
} }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requireActivity().onBackPressedDispatcher.addCallback(this) {
Log.i("StorageCheckFragment", "Not navigating back!")
}
}
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,

View file

@ -1,8 +1,11 @@
package com.stevesoltys.seedvault.ui.storage package com.stevesoltys.seedvault.ui.storage
import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.provider.DocumentsContract.buildTreeDocumentUri import android.provider.DocumentsContract.buildTreeDocumentUri
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import com.stevesoltys.seedvault.R
internal sealed class StorageOption { internal sealed class StorageOption {
abstract val id: String abstract val id: String
@ -46,3 +49,14 @@ internal sealed class StorageOption {
return id.hashCode() return id.hashCode()
} }
} }
internal class WebDavOption(context: Context) : StorageOption() {
override val id: String = "webdav"
override val icon: Drawable? = getDrawable(context, R.drawable.ic_cloud_circle)
override val title: String = context.getString(R.string.storage_webdav_option_title)
override val summary: String = context.getString(R.string.storage_webdav_option_summary)
override val availableBytes: Long? = null
override val requiresNetwork: Boolean = true
override val enabled: Boolean = true
override val nonDefaultAction: (() -> Unit)? = null
}

View file

@ -13,9 +13,11 @@ import android.provider.DocumentsContract.PROVIDER_INTERFACE
import android.provider.DocumentsContract.buildRootsUri import android.provider.DocumentsContract.buildRootsUri
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.saf.SafStorageOptions
import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
private val TAG = StorageRootFetcher::class.java.simpleName private val TAG = StorageOptionFetcher::class.java.simpleName
const val AUTHORITY_STORAGE = "com.android.externalstorage.documents" const val AUTHORITY_STORAGE = "com.android.externalstorage.documents"
const val ROOT_ID_DEVICE = "primary" const val ROOT_ID_DEVICE = "primary"
@ -30,7 +32,7 @@ internal interface RemovableStorageListener {
fun onStorageChanged() fun onStorageChanged()
} }
internal class StorageRootFetcher(private val context: Context, private val isRestore: Boolean) { internal class StorageOptionFetcher(private val context: Context, private val isRestore: Boolean) {
private val packageManager = context.packageManager private val packageManager = context.packageManager
private val contentResolver = context.contentResolver private val contentResolver = context.contentResolver
@ -60,7 +62,9 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
internal fun getRemovableStorageListener() = listener internal fun getRemovableStorageListener() = listener
internal fun getStorageOptions(): List<StorageOption> { internal fun getStorageOptions(): List<StorageOption> {
val roots = ArrayList<SafOption>() val roots = ArrayList<StorageOption>().apply {
add(WebDavOption(context))
}
val intent = Intent(PROVIDER_INTERFACE) val intent = Intent(PROVIDER_INTERFACE)
val providers = packageManager.queryIntentContentProviders(intent, 0) val providers = packageManager.queryIntentContentProviders(intent, 0)
for (info in providers) { for (info in providers) {

View file

@ -55,7 +55,7 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle?, savedInstanceState: Bundle?,
): View { ): View {
val v: View = inflater.inflate(R.layout.fragment_storage_root, container, false) val v: View = inflater.inflate(R.layout.fragment_storage_options, container, false)
titleView = v.requireViewById(R.id.titleView) titleView = v.requireViewById(R.id.titleView)
warningIcon = v.requireViewById(R.id.warningIcon) warningIcon = v.requireViewById(R.id.warningIcon)
@ -115,11 +115,21 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
} }
override fun onClick(storageOption: StorageOption) { override fun onClick(storageOption: StorageOption) {
if (storageOption is SafOption) { when (storageOption) {
is SafOption -> {
viewModel.onSafOptionChosen(storageOption) viewModel.onSafOptionChosen(storageOption)
openDocumentTree.launch(storageOption.uri) openDocumentTree.launch(storageOption.uri)
} else { }
throw IllegalArgumentException("Non-SAF storage not yet supported")
is WebDavOption -> {
val isRestore = requireArguments().getBoolean(INTENT_EXTRA_IS_RESTORE)
val f = WebDavConfigFragment.newInstance(isRestore)
parentFragmentManager.beginTransaction().apply {
replace(R.id.fragment, f)
addToBackStack("WebDAV")
commit()
}
}
} }
} }

View file

@ -1,32 +1,33 @@
package com.stevesoltys.seedvault.ui.storage package com.stevesoltys.seedvault.ui.storage
import android.annotation.UiThread
import android.app.Application import android.app.Application
import android.content.Context
import android.content.Context.USB_SERVICE
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.hardware.usb.UsbManager
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.isMassStorage import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.permitDiskReads import com.stevesoltys.seedvault.plugins.saf.SafHandler
import com.stevesoltys.seedvault.settings.BackupManagerSettings import com.stevesoltys.seedvault.plugins.saf.SafStorage
import com.stevesoltys.seedvault.settings.FlashDrive import com.stevesoltys.seedvault.plugins.webdav.WebDavConfig
import com.stevesoltys.seedvault.plugins.webdav.WebDavHandler
import com.stevesoltys.seedvault.plugins.webdav.WebDavProperties
import com.stevesoltys.seedvault.plugins.webdav.WebDavStoragePlugin
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.Storage
import com.stevesoltys.seedvault.ui.LiveEvent import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
import kotlinx.coroutines.Dispatchers
private val TAG = StorageViewModel::class.java.simpleName import kotlinx.coroutines.launch
internal abstract class StorageViewModel( internal abstract class StorageViewModel(
private val app: Application, private val app: Application,
protected val safHandler: SafHandler,
protected val webdavHandler: WebDavHandler,
protected val settingsManager: SettingsManager, protected val settingsManager: SettingsManager,
protected val storagePluginManager: StoragePluginManager,
) : AndroidViewModel(app), RemovableStorageListener { ) : AndroidViewModel(app), RemovableStorageListener {
private val mStorageOptions = MutableLiveData<List<StorageOption>>() private val mStorageOptions = MutableLiveData<List<StorageOption>>()
@ -38,38 +39,29 @@ internal abstract class StorageViewModel(
protected val mLocationChecked = MutableLiveEvent<LocationResult>() protected val mLocationChecked = MutableLiveEvent<LocationResult>()
internal val locationChecked: LiveEvent<LocationResult> get() = mLocationChecked internal val locationChecked: LiveEvent<LocationResult> get() = mLocationChecked
private val storageRootFetcher by lazy { StorageRootFetcher(app, isRestoreOperation) } private val storageOptionFetcher by lazy { StorageOptionFetcher(app, isRestoreOperation) }
private var safOption: SafOption? = null private var safOption: SafOption? = null
internal var isSetupWizard: Boolean = false internal var isSetupWizard: Boolean = false
internal val hasStorageSet: Boolean internal val hasStorageSet: Boolean
get() = settingsManager.getStorage() != null get() = storagePluginManager.storageProperties != null
abstract val isRestoreOperation: Boolean abstract val isRestoreOperation: Boolean
companion object {
internal fun validLocationIsSet(
context: Context,
settingsManager: SettingsManager,
): Boolean {
val storage = settingsManager.getStorage() ?: return false
if (storage.isUsb) return true
return permitDiskReads {
storage.getDocumentFile(context).isDirectory
}
}
}
internal fun loadStorageRoots() { internal fun loadStorageRoots() {
if (storageRootFetcher.getRemovableStorageListener() == null) { if (storageOptionFetcher.getRemovableStorageListener() == null) {
storageRootFetcher.setRemovableStorageListener(this) storageOptionFetcher.setRemovableStorageListener(this)
} }
Thread { Thread {
mStorageOptions.postValue(storageRootFetcher.getStorageOptions()) mStorageOptions.postValue(storageOptionFetcher.getStorageOptions())
}.start() }.start()
} }
override fun onStorageChanged() = loadStorageRoots() override fun onStorageChanged() = loadStorageRoots()
/**
* Remembers that the user chose SAF.
* Usually followed by a call of [onUriPermissionResultReceived].
*/
fun onSafOptionChosen(option: SafOption) { fun onSafOptionChosen(option: SafOption) {
safOption = option safOption = option
} }
@ -80,76 +72,40 @@ internal abstract class StorageViewModel(
mLocationChecked.setEvent(LocationResult(msg)) mLocationChecked.setEvent(LocationResult(msg))
return return
} }
require(safOption?.uri == uri) { "URIs differ: ${safOption?.uri} != $uri" }
val root = safOption ?: throw IllegalStateException("no storage root")
val safStorage = safHandler.onConfigReceived(uri, root)
// inform UI that a location has been successfully selected // inform UI that a location has been successfully selected
mLocationSet.setEvent(true) mLocationSet.setEvent(true)
// persist permission to access backup folder across reboots onSafUriSet(safStorage)
val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
app.contentResolver.takePersistableUriPermission(uri, takeFlags)
onLocationSet(uri)
} }
/** abstract fun onSafUriSet(safStorage: SafStorage)
* Saves the storage behind the given [Uri] (and saved [safOption]). abstract fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin)
*
* @return true if the storage is a USB flash drive, false otherwise.
*/
protected fun saveStorage(uri: Uri): Boolean {
// store backup storage location in settings
val storage = createStorage(uri)
return saveStorage(storage)
}
protected fun createStorage(uri: Uri): Storage {
val root = safOption ?: throw IllegalStateException("no storage root")
val name = if (root.isInternal()) {
"${root.title} (${app.getString(R.string.settings_backup_location_internal)})"
} else {
root.title
}
return Storage(uri, name, root.isUsb, root.requiresNetwork)
}
protected fun saveStorage(storage: Storage): Boolean {
settingsManager.setStorage(storage)
if (storage.isUsb) {
Log.d(TAG, "Selected storage is a removable USB device.")
val wasSaved = saveUsbDevice()
// reset stored flash drive, if we did not update it
if (!wasSaved) settingsManager.setFlashDrive(null)
} else {
settingsManager.setFlashDrive(null)
}
BackupManagerSettings.resetDefaults(app.contentResolver)
Log.d(TAG, "New storage location saved: ${storage.uri}")
return storage.isUsb
}
private fun saveUsbDevice(): Boolean {
val manager = app.getSystemService(USB_SERVICE) as UsbManager
manager.deviceList.values.forEach { device ->
if (device.isMassStorage()) {
val flashDrive = FlashDrive.from(device)
settingsManager.setFlashDrive(flashDrive)
Log.d(TAG, "Saved flash drive: $flashDrive")
return true
}
}
Log.e(TAG, "No USB device found even though we were expecting one.")
return false
}
abstract fun onLocationSet(uri: Uri)
override fun onCleared() { override fun onCleared() {
storageRootFetcher.setRemovableStorageListener(null) storageOptionFetcher.setRemovableStorageListener(null)
super.onCleared() super.onCleared()
} }
val webdavConfigState get() = webdavHandler.configState
fun onWebDavConfigReceived(url: String, user: String, pass: String) {
val config = WebDavConfig(url = url, username = user, password = pass)
viewModelScope.launch(Dispatchers.IO) {
webdavHandler.onConfigReceived(config)
}
}
fun resetWebDavConfig() = webdavHandler.resetConfigState()
@UiThread
fun onWebDavConfigSuccess(properties: WebDavProperties, plugin: WebDavStoragePlugin) {
mLocationSet.setEvent(true)
onWebDavConfigSet(properties, plugin)
}
} }

View file

@ -0,0 +1,133 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.ui.storage
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.Button
import android.widget.ProgressBar
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.transition.TransitionManager.beginDelayedTransition
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
import com.google.android.material.textfield.TextInputEditText
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.plugins.webdav.WebDavConfigState
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
import kotlinx.coroutines.launch
import org.koin.androidx.viewmodel.ext.android.getSharedViewModel
class WebDavConfigFragment : Fragment(), View.OnClickListener {
companion object {
fun newInstance(isRestore: Boolean): WebDavConfigFragment {
val f = WebDavConfigFragment()
f.arguments = Bundle().apply {
putBoolean(INTENT_EXTRA_IS_RESTORE, isRestore)
}
return f
}
}
private lateinit var viewModel: StorageViewModel
private lateinit var urlInput: TextInputEditText
private lateinit var userInput: TextInputEditText
private lateinit var passInput: TextInputEditText
private lateinit var button: Button
private lateinit var progressBar: ProgressBar
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val v: View = inflater.inflate(R.layout.fragment_webdav_config, container, false)
urlInput = v.requireViewById(R.id.webdavUrlInput)
userInput = v.requireViewById(R.id.webdavUserInput)
passInput = v.requireViewById(R.id.webDavPassInput)
button = v.requireViewById(R.id.webdavButton)
button.setOnClickListener(this)
progressBar = v.requireViewById(R.id.progressBar)
return v
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = if (requireArguments().getBoolean(INTENT_EXTRA_IS_RESTORE)) {
getSharedViewModel<RestoreStorageViewModel>()
} else {
getSharedViewModel<BackupStorageViewModel>()
}
lifecycleScope.launch {
viewModel.webdavConfigState.flowWithLifecycle(lifecycle, STARTED).collect {
onConfigStateChanged(it)
}
}
}
override fun onClick(v: View) {
if (urlInput.text.isNullOrBlank()) {
Snackbar.make(
requireView(),
R.string.storage_webdav_config_malformed_url,
LENGTH_LONG
).setAnchorView(button).show()
} else {
viewModel.onWebDavConfigReceived(
url = urlInput.text.toString(),
user = userInput.text.toString(),
pass = passInput.text.toString(),
)
}
}
override fun onDestroy() {
viewModel.resetWebDavConfig()
super.onDestroy()
}
private fun onConfigStateChanged(state: WebDavConfigState) {
when (state) {
WebDavConfigState.Empty -> {
}
WebDavConfigState.Checking -> {
beginDelayedTransition(requireView() as ViewGroup)
progressBar.visibility = VISIBLE
button.visibility = INVISIBLE
}
is WebDavConfigState.Success -> {
viewModel.onWebDavConfigSuccess(state.properties, state.plugin)
}
is WebDavConfigState.Error -> {
val s = if (state.e == null) {
getString(R.string.storage_check_fragment_backup_error)
} else {
getString(R.string.storage_check_fragment_backup_error) +
" ${state.e::class.java.simpleName} ${state.e.message}"
}
Snackbar.make(requireView(), s, LENGTH_LONG).setAnchorView(button).show()
beginDelayedTransition(requireView() as ViewGroup)
progressBar.visibility = INVISIBLE
button.visibility = VISIBLE
}
}
}
}

View file

@ -30,7 +30,6 @@ import java.security.MessageDigest
private val TAG = ApkBackup::class.java.simpleName private val TAG = ApkBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext")
internal class ApkBackup( internal class ApkBackup(
private val pm: PackageManager, private val pm: PackageManager,
private val crypto: Crypto, private val crypto: Crypto,

View file

@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
@ -28,7 +29,7 @@ internal class ApkBackupManager(
private val metadataManager: MetadataManager, private val metadataManager: MetadataManager,
private val packageService: PackageService, private val packageService: PackageService,
private val apkBackup: ApkBackup, private val apkBackup: ApkBackup,
private val plugin: StoragePlugin, private val pluginManager: StoragePluginManager,
private val nm: BackupNotificationManager, private val nm: BackupNotificationManager,
) { ) {
@ -50,7 +51,7 @@ internal class ApkBackupManager(
keepTrying { keepTrying {
// upload all local changes only at the end, // upload all local changes only at the end,
// so we don't have to re-upload the metadata // so we don't have to re-upload the metadata
plugin.getMetadataOutputStream().use { outputStream -> pluginManager.appPlugin.getMetadataOutputStream().use { outputStream ->
metadataManager.uploadMetadata(outputStream) metadataManager.uploadMetadata(outputStream)
} }
} }
@ -102,7 +103,7 @@ internal class ApkBackupManager(
return try { return try {
apkBackup.backupApkIfNecessary(packageInfo) { name -> apkBackup.backupApkIfNecessary(packageInfo) { name ->
val token = settingsManager.getToken() ?: throw IOException("no current token") val token = settingsManager.getToken() ?: throw IOException("no current token")
plugin.getOutputStream(token, name) pluginManager.appPlugin.getOutputStream(token, name)
}?.let { packageMetadata -> }?.let { packageMetadata ->
metadataManager.onApkBackedUp(packageInfo, packageMetadata) metadataManager.onApkBackedUp(packageInfo, packageMetadata)
true true
@ -125,7 +126,9 @@ internal class ApkBackupManager(
} }
} }
private suspend fun StoragePlugin.getMetadataOutputStream(token: Long? = null): OutputStream { private suspend fun StoragePlugin<*>.getMetadataOutputStream(
token: Long? = null,
): OutputStream {
val t = token ?: settingsManager.getToken() ?: throw IOException("no current token") val t = token ?: settingsManager.getToken() ?: throw IOException("no current token")
return getOutputStream(t, FILE_BACKUP_METADATA) return getOutputStream(t, FILE_BACKUP_METADATA)
} }

View file

@ -31,7 +31,7 @@ val workerModule = module {
metadataManager = get(), metadataManager = get(),
packageService = get(), packageService = get(),
apkBackup = get(), apkBackup = get(),
plugin = get(), pluginManager = get(),
nm = get() nm = get()
) )
} }

View file

@ -0,0 +1,16 @@
<!--
SPDX-FileCopyrightText: 2024 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/textColorSecondary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM16.5,16L8,16c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3l0.14,0.01C8.58,8.28 10.13,7 12,7c2.21,0 4,1.79 4,4h0.5c1.38,0 2.5,1.12 2.5,2.5S17.88,16 16.5,16z" />
</vector>

View file

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: 2024 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/webdavUrlLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="spread_inside">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/webdavUrlInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/storage_webdav_config_url"
android:inputType="text|textUri" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/webdavUserLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintBottom_toTopOf="@+id/webdavPassLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/webdavUrlLayout">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/webdavUserInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/storage_webdav_config_user"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/webdavPassLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/webdavUserLayout"
app:passwordToggleEnabled="true">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/webDavPassInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/storage_webdav_config_pass"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/webdavButton"
style="@style/SudPrimaryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/storage_webdav_config_button"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/webdavPassLayout"
app:layout_constraintVertical_bias="1.0" />
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="@+id/webdavButton"
app:layout_constraintEnd_toEndOf="@+id/webdavButton"
app:layout_constraintStart_toStartOf="@+id/webdavButton"
app:layout_constraintTop_toTopOf="@+id/webdavButton"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -97,6 +97,15 @@
<string name="storage_check_fragment_permission_error">Unable to get the permission to write to the backup location.</string> <string name="storage_check_fragment_permission_error">Unable to get the permission to write to the backup location.</string>
<string name="storage_check_fragment_error_button">Back</string> <string name="storage_check_fragment_error_button">Back</string>
<string name="storage_webdav_option_title">WebDAV Cloud (beta)</string>
<string name="storage_webdav_option_summary">Integrated direct WebDAV access</string>
<string name="storage_webdav_config_url">WebDAV URL</string>
<string name="storage_webdav_config_user">User name</string>
<string name="storage_webdav_config_pass">Password</string>
<string name="storage_webdav_config_button">Use WebDAV cloud</string>
<string name="storage_webdav_config_malformed_url">Invalid WebDAV URL</string>
<string name="storage_webdav_name">WebDAV %s</string>
<!-- Recovery Code --> <!-- Recovery Code -->
<string name="recovery_code_title">Recovery code</string> <string name="recovery_code_title">Recovery code</string>
<string name="recovery_code_12_word_intro">You need your 12-word recovery code to restore backed up data.</string> <string name="recovery_code_12_word_intro">You need your 12-word recovery code to restore backed up data.</string>

View file

@ -8,13 +8,15 @@ import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
import com.stevesoltys.seedvault.header.headerModule import com.stevesoltys.seedvault.header.headerModule
import com.stevesoltys.seedvault.metadata.metadataModule import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.saf.documentsProviderModule import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.restore.install.installModule
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.backupModule import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.transport.restore.restoreModule import com.stevesoltys.seedvault.transport.restore.restoreModule
import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidContext
import org.koin.core.KoinApplication
import org.koin.core.context.startKoin import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module import org.koin.dsl.module
class TestApp : App() { class TestApp : App() {
@ -31,14 +33,16 @@ class TestApp : App() {
single { SettingsManager(this@TestApp) } single { SettingsManager(this@TestApp) }
} }
override fun startKoin() = startKoin { override fun startKoin(): KoinApplication {
stopKoin()
return startKoin {
androidContext(this@TestApp) androidContext(this@TestApp)
modules( modules(
listOf( listOf(
testCryptoModule, testCryptoModule,
headerModule, headerModule,
metadataModule, metadataModule,
documentsProviderModule, // storage plugin storagePluginModuleSaf, // storage plugin
backupModule, backupModule,
restoreModule, restoreModule,
installModule, installModule,
@ -46,4 +50,5 @@ class TestApp : App() {
) )
) )
} }
}
} }

View file

@ -11,7 +11,6 @@ import io.mockk.mockkStatic
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@Suppress("BlockingMethodInNonBlockingContext")
internal class StoragePluginTest : BackupTest() { internal class StoragePluginTest : BackupTest() {
private val storage = mockk<DocumentsStorage>() private val storage = mockk<DocumentsStorage>()
@ -39,7 +38,7 @@ internal class StoragePluginTest : BackupTest() {
// get current set dir and for that the current token // get current set dir and for that the current token
every { storage getProperty "currentToken" } returns token every { storage getProperty "currentToken" } returns token
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
every { storage getProperty "storage" } returns null // just to check if isUsb every { storage getProperty "safStorage" } returns null // just to check if isUsb
coEvery { storage.getSetDir(token) } returns setDir coEvery { storage.getSetDir(token) } returns setDir
// delete contents of current set dir // delete contents of current set dir
coEvery { setDir.listFilesBlocking(context) } returns listOf(backupFile) coEvery { setDir.listFilesBlocking(context) } returns listOf(backupFile)

View file

@ -0,0 +1,116 @@
package com.stevesoltys.seedvault.plugins.webdav
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.TestApp
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.transport.TransportTest
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.fail
import org.junit.jupiter.api.assertThrows
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import java.io.IOException
import kotlin.random.Random
@RunWith(AndroidJUnit4::class)
@Config(
sdk = [33], // robolectric does not support 34, yet
application = TestApp::class
)
internal class WebDavStoragePluginTest : TransportTest() {
private val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig())
@Test
fun `test self-test`() = runBlocking {
assertTrue(plugin.test())
val plugin2 = WebDavStoragePlugin(context, WebDavConfig("https://github.com/", "", ""))
val e = assertThrows<Exception> {
assertFalse(plugin2.test())
}
println(e)
}
@Test
fun `test restore sets and reading+writing`() = runBlocking {
val token = System.currentTimeMillis()
val metadata = getRandomByteArray()
// need to initialize, to have root .SeedVaultAndroidBackup folder
plugin.initializeDevice()
plugin.startNewRestoreSet(token)
// initially, we don't have any backups
assertEquals(emptySet<EncryptedMetadata>(), plugin.getAvailableBackups()?.toSet())
// and no data
assertFalse(plugin.hasData(token, FILE_BACKUP_METADATA))
// write out the metadata file
plugin.getOutputStream(token, FILE_BACKUP_METADATA).use {
it.write(metadata)
}
try {
// now we have one backup matching our token
val backups = plugin.getAvailableBackups()?.toSet() ?: fail()
assertEquals(1, backups.size)
assertEquals(token, backups.first().token)
// read back written data
assertArrayEquals(
metadata,
plugin.getInputStream(token, FILE_BACKUP_METADATA).use { it.readAllBytes() },
)
// it has data now
assertTrue(plugin.hasData(token, FILE_BACKUP_METADATA))
} finally {
// remove data at the end, so consecutive test runs pass
plugin.removeData(token, FILE_BACKUP_METADATA)
}
}
@Test
fun `test streams for non-existent data`() = runBlocking {
val token = Random.nextLong(System.currentTimeMillis(), 9999999999999)
val file = getRandomString()
assertFalse(plugin.hasData(token, file))
assertThrows<IOException> {
plugin.getOutputStream(token, file).use { it.write(getRandomByteArray()) }
}
assertThrows<IOException> {
plugin.getInputStream(token, file).use {
it.readAllBytes()
}
}
Unit
}
@Test
fun `test missing root dir`() = runBlocking {
val plugin = WebDavStoragePlugin(context, WebDavTestConfig.getConfig(), getRandomString())
assertNull(plugin.getAvailableBackups())
assertFalse(plugin.hasData(42L, "foo"))
assertThrows<IOException> {
plugin.removeData(42L, "foo")
}
Unit
}
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.plugins.webdav
import org.junit.Assume.assumeFalse
import org.junit.jupiter.api.Assertions.fail
object WebDavTestConfig {
fun getConfig(): WebDavConfig {
assumeFalse(System.getenv("NEXTCLOUD_URL").isNullOrEmpty())
return WebDavConfig(
url = System.getenv("NEXTCLOUD_URL") ?: fail(),
username = System.getenv("NEXTCLOUD_USER") ?: fail(),
password = System.getenv("NEXTCLOUD_PASS") ?: fail(),
)
}
}

View file

@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.RestorableBackup
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.worker.ApkBackup import com.stevesoltys.seedvault.worker.ApkBackup
@ -38,7 +39,6 @@ import java.nio.file.Path
import kotlin.random.Random import kotlin.random.Random
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
@Suppress("BlockingMethodInNonBlockingContext")
internal class ApkBackupRestoreTest : TransportTest() { internal class ApkBackupRestoreTest : TransportTest() {
private val pm: PackageManager = mockk() private val pm: PackageManager = mockk()
@ -46,16 +46,17 @@ internal class ApkBackupRestoreTest : TransportTest() {
every { packageManager } returns pm every { packageManager } returns pm
} }
private val storagePluginManager: StoragePluginManager = mockk()
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
private val storagePlugin: StoragePlugin = mockk() private val storagePlugin: StoragePlugin<*> = mockk()
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
private val apkInstaller: ApkInstaller = mockk() private val apkInstaller: ApkInstaller = mockk()
private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager) private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager)
private val apkRestore: ApkRestore = ApkRestore( private val apkRestore: ApkRestore = ApkRestore(
context = strictContext, context = strictContext,
storagePlugin = storagePlugin, pluginManager = storagePluginManager,
legacyStoragePlugin = legacyStoragePlugin, legacyStoragePlugin = legacyStoragePlugin,
crypto = crypto, crypto = crypto,
splitCompatChecker = splitCompatChecker, splitCompatChecker = splitCompatChecker,
@ -90,6 +91,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
init { init {
mockkStatic(PackageUtils::class) mockkStatic(PackageUtils::class)
every { storagePluginManager.appPlugin } returns storagePlugin
} }
@Test @Test

View file

@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.RestorableBackup
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
@ -41,7 +42,6 @@ import java.io.IOException
import java.nio.file.Path import java.nio.file.Path
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
internal class ApkRestoreTest : TransportTest() { internal class ApkRestoreTest : TransportTest() {
@ -49,18 +49,19 @@ internal class ApkRestoreTest : TransportTest() {
private val strictContext: Context = mockk<Context>().apply { private val strictContext: Context = mockk<Context>().apply {
every { packageManager } returns pm every { packageManager } returns pm
} }
private val storagePlugin: StoragePlugin = mockk() private val storagePluginManager: StoragePluginManager = mockk()
private val storagePlugin: StoragePlugin<*> = mockk()
private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
private val apkInstaller: ApkInstaller = mockk() private val apkInstaller: ApkInstaller = mockk()
private val apkRestore: ApkRestore = ApkRestore( private val apkRestore: ApkRestore = ApkRestore(
strictContext, context = strictContext,
storagePlugin, pluginManager = storagePluginManager,
legacyStoragePlugin, legacyStoragePlugin = legacyStoragePlugin,
crypto, crypto = crypto,
splitCompatChecker, splitCompatChecker = splitCompatChecker,
apkInstaller apkInstaller = apkInstaller,
) )
private val icon: Drawable = mockk() private val icon: Drawable = mockk()
@ -85,6 +86,8 @@ internal class ApkRestoreTest : TransportTest() {
init { init {
// as we don't do strict signature checking, we can use a relaxed mock // as we don't do strict signature checking, we can use a relaxed mock
packageInfo.signingInfo = mockk(relaxed = true) packageInfo.signingInfo = mockk(relaxed = true)
every { storagePluginManager.appPlugin } returns storagePlugin
} }
@Test @Test

View file

@ -0,0 +1,151 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.storage
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.plugins.webdav.WebDavTestConfig
import com.stevesoltys.seedvault.transport.backup.BackupTest
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.calyxos.backup.storage.api.StoredSnapshot
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.jupiter.api.assertThrows
import java.io.IOException
internal class WebDavStoragePluginTest : BackupTest() {
private val keyManager: KeyManager = mockk()
private val plugin = WebDavStoragePlugin(keyManager, "foo", WebDavTestConfig.getConfig())
private val snapshot = StoredSnapshot("foo.sv", System.currentTimeMillis())
@Test
fun `test chunks`() = runBlocking {
val chunkId1 = getRandomByteArray(32).toHexString()
val chunkBytes1 = getRandomByteArray()
// init to create root folder
plugin.init()
// first we don't have any chunks
assertEquals(emptyList<String>(), plugin.getAvailableChunkIds())
// we write out chunk1
plugin.getChunkOutputStream(chunkId1).use {
it.write(chunkBytes1)
}
try {
// now we have the ID of chunk1
assertEquals(listOf(chunkId1), plugin.getAvailableChunkIds())
// reading chunk1 matches what we wrote
assertArrayEquals(
chunkBytes1,
plugin.getChunkInputStream(snapshot, chunkId1).readAllBytes(),
)
} finally {
// delete chunk again
plugin.deleteChunks(listOf(chunkId1))
}
}
@Test
fun `test snapshots`() = runBlocking {
val snapshotBytes = getRandomByteArray()
// init to create root folder
plugin.init()
// first we don't have any snapshots
assertEquals(emptyList<StoredSnapshot>(), plugin.getCurrentBackupSnapshots())
assertEquals(emptyList<StoredSnapshot>(), plugin.getBackupSnapshotsForRestore())
// now write one snapshot
plugin.getBackupSnapshotOutputStream(snapshot.timestamp).use {
it.write(snapshotBytes)
}
try {
// now we have that one snapshot
assertEquals(listOf(snapshot), plugin.getCurrentBackupSnapshots())
assertEquals(listOf(snapshot), plugin.getBackupSnapshotsForRestore())
// read back written snapshot
assertArrayEquals(
snapshotBytes,
plugin.getBackupSnapshotInputStream(snapshot).readAllBytes(),
)
// other device writes another snapshot
val otherPlugin = WebDavStoragePlugin(keyManager, "bar", WebDavTestConfig.getConfig())
val otherSnapshot = StoredSnapshot("bar.sv", System.currentTimeMillis())
val otherSnapshotBytes = getRandomByteArray()
assertEquals(emptyList<String>(), otherPlugin.getAvailableChunkIds())
otherPlugin.getBackupSnapshotOutputStream(otherSnapshot.timestamp).use {
it.write(otherSnapshotBytes)
}
try {
// now that initial one snapshot is still the only current, but restore has both
assertEquals(listOf(snapshot), plugin.getCurrentBackupSnapshots())
assertEquals(
setOf(snapshot, otherSnapshot),
plugin.getBackupSnapshotsForRestore().toSet(), // set to avoid sorting issues
)
} finally {
plugin.deleteBackupSnapshot(otherSnapshot)
}
} finally {
plugin.deleteBackupSnapshot(snapshot)
}
}
@Test
fun `test missing root dir`() = runBlocking {
val plugin = WebDavStoragePlugin(
keyManager = keyManager,
androidId = "foo",
webDavConfig = WebDavTestConfig.getConfig(),
root = getRandomString(),
)
assertThrows<IOException> {
plugin.getCurrentBackupSnapshots()
}
assertThrows<IOException> {
plugin.getBackupSnapshotsForRestore()
}
assertThrows<IOException> {
plugin.getAvailableChunkIds()
}
assertThrows<IOException> {
plugin.deleteChunks(listOf("foo"))
}
assertThrows<IOException> {
plugin.deleteBackupSnapshot(snapshot)
}
assertThrows<IOException> {
plugin.getBackupSnapshotOutputStream(snapshot.timestamp).close()
}
assertThrows<IOException> {
plugin.getBackupSnapshotInputStream(snapshot).use { it.readAllBytes() }
}
assertThrows<IOException> {
plugin.getChunkOutputStream("foo").close()
}
assertThrows<IOException> {
plugin.getChunkInputStream(snapshot, "foo").use { it.readAllBytes() }
}
Unit
}
}
private fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }

View file

@ -17,6 +17,7 @@ import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.transport.backup.BackupCoordinator import com.stevesoltys.seedvault.transport.backup.BackupCoordinator
import com.stevesoltys.seedvault.transport.backup.FullBackup import com.stevesoltys.seedvault.transport.backup.FullBackup
@ -46,7 +47,6 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class CoordinatorIntegrationTest : TransportTest() { internal class CoordinatorIntegrationTest : TransportTest() {
private val inputFactory = mockk<InputFactory>() private val inputFactory = mockk<InputFactory>()
@ -58,18 +58,20 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val metadataReader = MetadataReaderImpl(cryptoImpl) private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val notificationManager = mockk<BackupNotificationManager>() private val notificationManager = mockk<BackupNotificationManager>()
private val dbManager = TestKvDbManager() private val dbManager = TestKvDbManager()
private val storagePluginManager: StoragePluginManager = mockk()
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyPlugin = mockk<LegacyStoragePlugin>() private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val backupPlugin = mockk<StoragePlugin>() private val backupPlugin = mockk<StoragePlugin<*>>()
private val kvBackup = private val kvBackup =
KVBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl, dbManager) KVBackup(storagePluginManager, settingsManager, inputFactory, cryptoImpl, dbManager)
private val fullBackup = FullBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl) private val fullBackup =
FullBackup(storagePluginManager, settingsManager, inputFactory, cryptoImpl)
private val apkBackup = mockk<ApkBackup>() private val apkBackup = mockk<ApkBackup>()
private val packageService: PackageService = mockk() private val packageService: PackageService = mockk()
private val backup = BackupCoordinator( private val backup = BackupCoordinator(
context, context,
backupPlugin, storagePluginManager,
kvBackup, kvBackup,
fullBackup, fullBackup,
clock, clock,
@ -80,7 +82,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
) )
private val kvRestore = KVRestore( private val kvRestore = KVRestore(
backupPlugin, storagePluginManager,
legacyPlugin, legacyPlugin,
outputFactory, outputFactory,
headerReader, headerReader,
@ -88,14 +90,14 @@ internal class CoordinatorIntegrationTest : TransportTest() {
dbManager dbManager
) )
private val fullRestore = private val fullRestore =
FullRestore(backupPlugin, legacyPlugin, outputFactory, headerReader, cryptoImpl) FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl)
private val restore = RestoreCoordinator( private val restore = RestoreCoordinator(
context, context,
crypto, crypto,
settingsManager, settingsManager,
metadataManager, metadataManager,
notificationManager, notificationManager,
backupPlugin, storagePluginManager,
kvRestore, kvRestore,
fullRestore, fullRestore,
metadataReader metadataReader
@ -113,6 +115,10 @@ internal class CoordinatorIntegrationTest : TransportTest() {
// as we use real crypto, we need a real name for packageInfo // as we use real crypto, we need a real name for packageInfo
private val realName = cryptoImpl.getNameForPackage(salt, packageInfo.packageName) private val realName = cryptoImpl.getNameForPackage(salt, packageInfo.packageName)
init {
every { storagePluginManager.appPlugin } returns backupPlugin
}
@Test @Test
fun `test key-value backup and restore with 2 records`() = runBlocking { fun `test key-value backup and restore with 2 records`() = runBlocking {
val value = CapturingSlot<ByteArray>() val value = CapturingSlot<ByteArray>()

View file

@ -22,6 +22,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.slot
import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
import kotlin.random.Random import kotlin.random.Random
@ -70,13 +71,24 @@ internal abstract class TransportTest {
init { init {
mockkStatic(Log::class) mockkStatic(Log::class)
val logTagSlot = slot<String>()
val logMsgSlot = slot<String>()
val logExSlot = slot<Throwable>()
every { Log.v(any(), any()) } returns 0 every { Log.v(any(), any()) } returns 0
every { Log.d(any(), any()) } returns 0 every { Log.d(capture(logTagSlot), capture(logMsgSlot)) } answers {
println("${logTagSlot.captured} - ${logMsgSlot.captured}")
0
}
every { Log.d(any(), any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0 every { Log.i(any(), any()) } returns 0
every { Log.w(any(), ofType(String::class)) } returns 0 every { Log.w(any(), ofType(String::class)) } returns 0
every { Log.w(any(), ofType(String::class), any()) } returns 0 every { Log.w(any(), ofType(String::class), any()) } returns 0
every { Log.e(any(), any()) } returns 0 every { Log.e(any(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0 every { Log.e(capture(logTagSlot), capture(logMsgSlot), capture(logExSlot)) } answers {
println("${logTagSlot.captured} - ${logMsgSlot.captured} ${logExSlot.captured}")
logExSlot.captured.printStackTrace()
0
}
} }
} }

View file

@ -16,8 +16,9 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.settings.Storage import com.stevesoltys.seedvault.plugins.saf.SafStorage
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.worker.ApkBackup import com.stevesoltys.seedvault.worker.ApkBackup
import io.mockk.Runs import io.mockk.Runs
@ -33,10 +34,9 @@ import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class BackupCoordinatorTest : BackupTest() { internal class BackupCoordinatorTest : BackupTest() {
private val plugin = mockk<StoragePlugin>() private val pluginManager = mockk<StoragePluginManager>()
private val kv = mockk<KVBackup>() private val kv = mockk<KVBackup>()
private val full = mockk<FullBackup>() private val full = mockk<FullBackup>()
private val apkBackup = mockk<ApkBackup>() private val apkBackup = mockk<ApkBackup>()
@ -44,27 +44,32 @@ internal class BackupCoordinatorTest : BackupTest() {
private val packageService = mockk<PackageService>() private val packageService = mockk<PackageService>()
private val backup = BackupCoordinator( private val backup = BackupCoordinator(
context, context = context,
plugin, pluginManager = pluginManager,
kv, kv = kv,
full, full = full,
clock, clock = clock,
packageService, packageService = packageService,
metadataManager, metadataManager = metadataManager,
settingsManager, settingsManager = settingsManager,
notificationManager nm = notificationManager,
) )
private val plugin = mockk<StoragePlugin<*>>()
private val metadataOutputStream = mockk<OutputStream>() private val metadataOutputStream = mockk<OutputStream>()
private val fileDescriptor: ParcelFileDescriptor = mockk() private val fileDescriptor: ParcelFileDescriptor = mockk()
private val packageMetadata: PackageMetadata = mockk() private val packageMetadata: PackageMetadata = mockk()
private val storage = Storage( private val safStorage = SafStorage(
uri = Uri.EMPTY, config = Uri.EMPTY,
name = getRandomString(), name = getRandomString(),
isUsb = false, isUsb = false,
requiresNetwork = false requiresNetwork = false,
) )
init {
every { pluginManager.appPlugin } returns plugin
}
@Test @Test
fun `device initialization succeeds and delegates to plugin`() = runBlocking { fun `device initialization succeeds and delegates to plugin`() = runBlocking {
expectStartNewRestoreSet() expectStartNewRestoreSet()
@ -90,7 +95,7 @@ internal class BackupCoordinatorTest : BackupTest() {
expectStartNewRestoreSet() expectStartNewRestoreSet()
coEvery { plugin.initializeDevice() } throws IOException() coEvery { plugin.initializeDevice() } throws IOException()
every { metadataManager.requiresInit } returns maybeTrue every { metadataManager.requiresInit } returns maybeTrue
every { settingsManager.canDoBackupNow() } returns !maybeTrue every { pluginManager.canDoBackupNow() } returns !maybeTrue
every { notificationManager.onBackupError() } just Runs every { notificationManager.onBackupError() } just Runs
assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
@ -109,7 +114,7 @@ internal class BackupCoordinatorTest : BackupTest() {
expectStartNewRestoreSet() expectStartNewRestoreSet()
coEvery { plugin.initializeDevice() } throws IOException() coEvery { plugin.initializeDevice() } throws IOException()
every { metadataManager.requiresInit } returns false every { metadataManager.requiresInit } returns false
every { settingsManager.canDoBackupNow() } returns false every { pluginManager.canDoBackupNow() } returns false
assertEquals(TRANSPORT_ERROR, backup.initializeDevice()) assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
@ -125,7 +130,7 @@ internal class BackupCoordinatorTest : BackupTest() {
fun `performIncrementalBackup of @pm@ causes re-init when legacy format`() = runBlocking { fun `performIncrementalBackup of @pm@ causes re-init when legacy format`() = runBlocking {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
every { settingsManager.canDoBackupNow() } returns true every { pluginManager.canDoBackupNow() } returns true
every { metadataManager.requiresInit } returns true every { metadataManager.requiresInit } returns true
// start new restore set // start new restore set
@ -224,7 +229,7 @@ internal class BackupCoordinatorTest : BackupTest() {
every { kv.getCurrentSize() } returns 42L every { kv.getCurrentSize() } returns 42L
coEvery { kv.finishBackup() } returns TRANSPORT_OK coEvery { kv.finishBackup() } returns TRANSPORT_OK
every { settingsManager.canDoBackupNow() } returns false every { pluginManager.canDoBackupNow() } returns false
assertEquals(TRANSPORT_OK, backup.finishBackup()) assertEquals(TRANSPORT_OK, backup.finishBackup())
} }
@ -290,7 +295,7 @@ internal class BackupCoordinatorTest : BackupTest() {
) )
} just Runs } just Runs
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
every { settingsManager.getStorage() } returns storage every { pluginManager.storageProperties } returns safStorage
every { settingsManager.useMeteredNetwork } returns false every { settingsManager.useMeteredNetwork } returns false
every { metadataOutputStream.close() } just Runs every { metadataOutputStream.close() } just Runs
@ -340,7 +345,7 @@ internal class BackupCoordinatorTest : BackupTest() {
) )
} just Runs } just Runs
coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs coEvery { full.cancelFullBackup(token, metadata.salt, false) } just Runs
every { settingsManager.getStorage() } returns storage every { pluginManager.storageProperties } returns safStorage
every { settingsManager.useMeteredNetwork } returns false every { settingsManager.useMeteredNetwork } returns false
every { metadataOutputStream.close() } just Runs every { metadataOutputStream.close() } just Runs

View file

@ -7,6 +7,7 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForFull import com.stevesoltys.seedvault.header.getADForFull
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
@ -21,16 +22,20 @@ import java.io.FileInputStream
import java.io.IOException import java.io.IOException
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullBackupTest : BackupTest() { internal class FullBackupTest : BackupTest() {
private val plugin = mockk<StoragePlugin>() private val storagePluginManager: StoragePluginManager = mockk()
private val backup = FullBackup(plugin, settingsManager, inputFactory, crypto) private val plugin = mockk<StoragePlugin<*>>()
private val backup = FullBackup(storagePluginManager, settingsManager, inputFactory, crypto)
private val bytes = ByteArray(23).apply { Random.nextBytes(this) } private val bytes = ByteArray(23).apply { Random.nextBytes(this) }
private val inputStream = mockk<FileInputStream>() private val inputStream = mockk<FileInputStream>()
private val ad = getADForFull(VERSION, packageInfo.packageName) private val ad = getADForFull(VERSION, packageInfo.packageName)
init {
every { storagePluginManager.appPlugin } returns plugin
}
@Test @Test
fun `has no initial state`() { fun `has no initial state`() {
assertFalse(backup.hasState()) assertFalse(backup.hasState())

View file

@ -13,6 +13,7 @@ import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.header.getADForKV
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import io.mockk.CapturingSlot import io.mockk.CapturingSlot
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
@ -30,22 +31,26 @@ import java.io.ByteArrayInputStream
import java.io.IOException import java.io.IOException
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVBackupTest : BackupTest() { internal class KVBackupTest : BackupTest() {
private val plugin = mockk<StoragePlugin>() private val pluginManager = mockk<StoragePluginManager>()
private val dataInput = mockk<BackupDataInput>() private val dataInput = mockk<BackupDataInput>()
private val dbManager = mockk<KvDbManager>() private val dbManager = mockk<KvDbManager>()
private val backup = KVBackup(plugin, settingsManager, inputFactory, crypto, dbManager) private val backup = KVBackup(pluginManager, settingsManager, inputFactory, crypto, dbManager)
private val db = mockk<KVDb>() private val db = mockk<KVDb>()
private val plugin = mockk<StoragePlugin<*>>()
private val packageName = packageInfo.packageName private val packageName = packageInfo.packageName
private val key = getRandomString(MAX_KEY_LENGTH_SIZE) private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
private val dataValue = Random.nextBytes(23) private val dataValue = Random.nextBytes(23)
private val dbBytes = Random.nextBytes(42) private val dbBytes = Random.nextBytes(42)
private val inputStream = ByteArrayInputStream(dbBytes) private val inputStream = ByteArrayInputStream(dbBytes)
init {
every { pluginManager.appPlugin } returns plugin
}
@Test @Test
fun `has no initial state`() { fun `has no initial state`() {
assertFalse(backup.hasState()) assertFalse(backup.hasState())
@ -231,7 +236,7 @@ internal class KVBackupTest : BackupTest() {
every { dbManager.existsDb(pmPackageInfo.packageName) } returns false every { dbManager.existsDb(pmPackageInfo.packageName) } returns false
every { crypto.getNameForPackage(salt, pmPackageInfo.packageName) } returns name every { crypto.getNameForPackage(salt, pmPackageInfo.packageName) } returns name
every { dbManager.getDb(pmPackageInfo.packageName) } returns db every { dbManager.getDb(pmPackageInfo.packageName) } returns db
every { settingsManager.canDoBackupNow() } returns false every { pluginManager.canDoBackupNow() } returns false
every { db.put(key, dataValue) } just Runs every { db.put(key, dataValue) } just Runs
getDataInput(listOf(true, false)) getDataInput(listOf(true, false))

View file

@ -13,6 +13,7 @@ import com.stevesoltys.seedvault.header.VersionHeader
import com.stevesoltys.seedvault.header.getADForFull import com.stevesoltys.seedvault.header.getADForFull
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import io.mockk.CapturingSlot import io.mockk.CapturingSlot
import io.mockk.Runs import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
@ -31,17 +32,27 @@ import java.io.IOException
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class FullRestoreTest : RestoreTest() { internal class FullRestoreTest : RestoreTest() {
private val plugin = mockk<StoragePlugin>() private val storagePluginManager: StoragePluginManager = mockk()
private val plugin = mockk<StoragePlugin<*>>()
private val legacyPlugin = mockk<LegacyStoragePlugin>() private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val restore = FullRestore(plugin, legacyPlugin, outputFactory, headerReader, crypto) private val restore = FullRestore(
pluginManager = storagePluginManager,
legacyPlugin = legacyPlugin,
outputFactory = outputFactory,
headerReader = headerReader,
crypto = crypto,
)
private val encrypted = getRandomByteArray() private val encrypted = getRandomByteArray()
private val outputStream = ByteArrayOutputStream() private val outputStream = ByteArrayOutputStream()
private val ad = getADForFull(VERSION, packageInfo.packageName) private val ad = getADForFull(VERSION, packageInfo.packageName)
init {
every { storagePluginManager.appPlugin } returns plugin
}
@Test @Test
fun `has no initial state`() { fun `has no initial state`() {
assertFalse(restore.hasState()) assertFalse(restore.hasState())

View file

@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.header.VersionHeader
import com.stevesoltys.seedvault.header.getADForKV import com.stevesoltys.seedvault.header.getADForKV
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.transport.backup.KVDb import com.stevesoltys.seedvault.transport.backup.KVDb
import com.stevesoltys.seedvault.transport.backup.KvDbManager import com.stevesoltys.seedvault.transport.backup.KvDbManager
import io.mockk.Runs import io.mockk.Runs
@ -33,15 +34,22 @@ import java.security.GeneralSecurityException
import java.util.zip.GZIPOutputStream import java.util.zip.GZIPOutputStream
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class KVRestoreTest : RestoreTest() { internal class KVRestoreTest : RestoreTest() {
private val plugin = mockk<StoragePlugin>() private val storagePluginManager: StoragePluginManager = mockk()
private val plugin = mockk<StoragePlugin<*>>()
@Suppress("DEPRECATION")
private val legacyPlugin = mockk<LegacyStoragePlugin>() private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val dbManager = mockk<KvDbManager>() private val dbManager = mockk<KvDbManager>()
private val output = mockk<BackupDataOutput>() private val output = mockk<BackupDataOutput>()
private val restore = private val restore = KVRestore(
KVRestore(plugin, legacyPlugin, outputFactory, headerReader, crypto, dbManager) pluginManager = storagePluginManager,
legacyPlugin = legacyPlugin,
outputFactory = outputFactory,
headerReader = headerReader,
crypto = crypto,
dbManager = dbManager,
)
private val db = mockk<KVDb>() private val db = mockk<KVDb>()
private val ad = getADForKV(VERSION, packageInfo.packageName) private val ad = getADForKV(VERSION, packageInfo.packageName)
@ -60,6 +68,8 @@ internal class KVRestoreTest : RestoreTest() {
init { init {
// for InputStream#readBytes() // for InputStream#readBytes()
mockkStatic("kotlin.io.ByteStreamsKt") mockkStatic("kotlin.io.ByteStreamsKt")
every { storagePluginManager.appPlugin } returns plugin
} }
@Test @Test
@ -180,7 +190,6 @@ internal class KVRestoreTest : RestoreTest() {
} }
@Test @Test
@Suppress("Deprecation")
fun `v0 listing records throws`() = runBlocking { fun `v0 listing records throws`() = runBlocking {
restore.initializeState(0x00, token, name, packageInfo) restore.initializeState(0x00, token, name, packageInfo)

View file

@ -16,7 +16,8 @@ import com.stevesoltys.seedvault.metadata.MetadataReader
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
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.settings.Storage import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.SafStorage
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs import io.mockk.Runs
@ -35,29 +36,29 @@ import java.io.IOException
import java.io.InputStream import java.io.InputStream
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class RestoreCoordinatorTest : TransportTest() { internal class RestoreCoordinatorTest : TransportTest() {
private val notificationManager: BackupNotificationManager = mockk() private val notificationManager: BackupNotificationManager = mockk()
private val plugin = mockk<StoragePlugin>() private val storagePluginManager: StoragePluginManager = mockk()
private val plugin = mockk<StoragePlugin<*>>()
private val kv = mockk<KVRestore>() private val kv = mockk<KVRestore>()
private val full = mockk<FullRestore>() private val full = mockk<FullRestore>()
private val metadataReader = mockk<MetadataReader>() private val metadataReader = mockk<MetadataReader>()
private val restore = RestoreCoordinator( private val restore = RestoreCoordinator(
context, context = context,
crypto, crypto = crypto,
settingsManager, settingsManager = settingsManager,
metadataManager, metadataManager = metadataManager,
notificationManager, notificationManager = notificationManager,
plugin, pluginManager = storagePluginManager,
kv, kv = kv,
full, full = full,
metadataReader metadataReader = metadataReader,
) )
private val inputStream = mockk<InputStream>() private val inputStream = mockk<InputStream>()
private val storage: Storage = mockk() private val safStorage: SafStorage = mockk()
private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" } private val packageInfo2 = PackageInfo().apply { packageName = "org.example2" }
private val packageInfoArray = arrayOf(packageInfo) private val packageInfoArray = arrayOf(packageInfo)
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2) private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
@ -71,6 +72,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
init { init {
metadata.packageMetadataMap[packageInfo2.packageName] = metadata.packageMetadataMap[packageInfo2.packageName] =
PackageMetadata(backupType = BackupType.FULL) PackageMetadata(backupType = BackupType.FULL)
every { storagePluginManager.appPlugin } returns plugin
} }
@Test @Test
@ -164,10 +167,10 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test @Test
fun `startRestore() optimized auto-restore with removed storage shows notification`() = fun `startRestore() optimized auto-restore with removed storage shows notification`() =
runBlocking { runBlocking {
every { settingsManager.getStorage() } returns storage every { storagePluginManager.storageProperties } returns safStorage
every { storage.isUnavailableUsb(context) } returns true every { safStorage.isUnavailableUsb(context) } returns true
every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L) every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L)
every { storage.name } returns storageName every { safStorage.name } returns storageName
every { every {
notificationManager.onRemovableStorageNotAvailableForRestore( notificationManager.onRemovableStorageNotAvailableForRestore(
packageName, packageName,
@ -188,8 +191,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test @Test
fun `startRestore() optimized auto-restore with available storage shows no notification`() = fun `startRestore() optimized auto-restore with available storage shows no notification`() =
runBlocking { runBlocking {
every { settingsManager.getStorage() } returns storage every { storagePluginManager.storageProperties } returns safStorage
every { storage.isUnavailableUsb(context) } returns false every { safStorage.isUnavailableUsb(context) } returns false
restore.beforeStartRestore(metadata) restore.beforeStartRestore(metadata)
assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray)) assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray))
@ -204,8 +207,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
@Test @Test
fun `startRestore() with removed storage shows no notification`() = runBlocking { fun `startRestore() with removed storage shows no notification`() = runBlocking {
every { settingsManager.getStorage() } returns storage every { storagePluginManager.storageProperties } returns safStorage
every { storage.isUnavailableUsb(context) } returns true every { safStorage.isUnavailableUsb(context) } returns true
every { metadataManager.getPackageMetadata(packageName) } returns null every { metadataManager.getPackageMetadata(packageName) } returns null
assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray)) assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray))

View file

@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.header.HeaderReaderImpl
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.toByteArrayFromHex import com.stevesoltys.seedvault.toByteArrayFromHex
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.transport.backup.KvDbManager import com.stevesoltys.seedvault.transport.backup.KvDbManager
@ -35,7 +36,6 @@ import javax.crypto.spec.SecretKeySpec
/** /**
* Tests that we can still restore Version 0 backups with current code. * Tests that we can still restore Version 0 backups with current code.
*/ */
@Suppress("BlockingMethodInNonBlockingContext")
internal class RestoreV0IntegrationTest : TransportTest() { internal class RestoreV0IntegrationTest : TransportTest() {
private val outputFactory = mockk<OutputFactory>() private val outputFactory = mockk<OutputFactory>()
@ -49,30 +49,31 @@ internal class RestoreV0IntegrationTest : TransportTest() {
private val dbManager = mockk<KvDbManager>() private val dbManager = mockk<KvDbManager>()
private val metadataReader = MetadataReaderImpl(cryptoImpl) private val metadataReader = MetadataReaderImpl(cryptoImpl)
private val notificationManager = mockk<BackupNotificationManager>() private val notificationManager = mockk<BackupNotificationManager>()
private val storagePluginManager: StoragePluginManager = mockk()
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyPlugin = mockk<LegacyStoragePlugin>() private val legacyPlugin = mockk<LegacyStoragePlugin>()
private val backupPlugin = mockk<StoragePlugin>() private val backupPlugin = mockk<StoragePlugin<*>>()
private val kvRestore = KVRestore( private val kvRestore = KVRestore(
backupPlugin, pluginManager = storagePluginManager,
legacyPlugin, legacyPlugin = legacyPlugin,
outputFactory, outputFactory = outputFactory,
headerReader, headerReader = headerReader,
cryptoImpl, crypto = cryptoImpl,
dbManager dbManager = dbManager,
) )
private val fullRestore = private val fullRestore =
FullRestore(backupPlugin, legacyPlugin, outputFactory, headerReader, cryptoImpl) FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl)
private val restore = RestoreCoordinator( private val restore = RestoreCoordinator(
context, context = context,
crypto, crypto = crypto,
settingsManager, settingsManager = settingsManager,
metadataManager, metadataManager = metadataManager,
notificationManager, notificationManager = notificationManager,
backupPlugin, pluginManager = storagePluginManager,
kvRestore, kv = kvRestore,
fullRestore, full = fullRestore,
metadataReader metadataReader = metadataReader,
).apply { beforeStartRestore(metadata.copy(version = 0x00)) } ).apply { beforeStartRestore(metadata.copy(version = 0x00)) }
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true) private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
@ -116,6 +117,10 @@ internal class RestoreV0IntegrationTest : TransportTest() {
private val key2 = "RestoreKey2" private val key2 = "RestoreKey2"
private val key264 = key2.encodeBase64() private val key264 = key2.encodeBase64()
init {
every { storagePluginManager.appPlugin } returns backupPlugin
}
@Test @Test
fun `test key-value backup and restore with 2 records`() = runBlocking { fun `test key-value backup and restore with 2 records`() = runBlocking {
val encryptedAppData = ("00002A2C701AA7C91D1286E265D29169B25C41E6D0" + val encryptedAppData = ("00002A2C701AA7C91D1286E265D29169B25C41E6D0" +

View file

@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.plugins.StoragePlugin import com.stevesoltys.seedvault.plugins.StoragePlugin
import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA import com.stevesoltys.seedvault.plugins.saf.FILE_BACKUP_METADATA
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
@ -32,7 +33,8 @@ internal class ApkBackupManagerTest : TransportTest() {
private val packageService: PackageService = mockk() private val packageService: PackageService = mockk()
private val apkBackup: ApkBackup = mockk() private val apkBackup: ApkBackup = mockk()
private val plugin: StoragePlugin = mockk() private val storagePluginManager: StoragePluginManager = mockk()
private val plugin: StoragePlugin<*> = mockk()
private val nm: BackupNotificationManager = mockk() private val nm: BackupNotificationManager = mockk()
private val apkBackupManager = ApkBackupManager( private val apkBackupManager = ApkBackupManager(
@ -41,13 +43,17 @@ internal class ApkBackupManagerTest : TransportTest() {
metadataManager = metadataManager, metadataManager = metadataManager,
packageService = packageService, packageService = packageService,
apkBackup = apkBackup, apkBackup = apkBackup,
plugin = plugin, pluginManager = storagePluginManager,
nm = nm, nm = nm,
) )
private val metadataOutputStream = mockk<OutputStream>() private val metadataOutputStream = mockk<OutputStream>()
private val packageMetadata: PackageMetadata = mockk() private val packageMetadata: PackageMetadata = mockk()
init {
every { storagePluginManager.appPlugin } returns plugin
}
@Test @Test
fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking { fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking {
every { nm.onAppsNotBackedUp() } just Runs every { nm.onAppsNotBackedUp() } just Runs

View file

@ -38,7 +38,6 @@ import java.io.OutputStream
import java.nio.file.Path import java.nio.file.Path
import kotlin.random.Random import kotlin.random.Random
@Suppress("BlockingMethodInNonBlockingContext")
internal class ApkBackupTest : BackupTest() { internal class ApkBackupTest : BackupTest() {
private val pm: PackageManager = mockk() private val pm: PackageManager = mockk()

17
libs/dav4jvm/Android.bp Normal file
View file

@ -0,0 +1,17 @@
java_import {
name: "seedvault-lib-dav4jvm",
jars: ["dav4jvm-2.2.1.jar"],
sdk_version: "current",
}
java_import {
name: "seedvault-lib-okhttp",
jars: ["okhttp-4.11.0.jar"],
sdk_version: "current",
}
java_import {
name: "seedvault-lib-okio",
jars: ["okio-jvm-3.7.0.jar"],
sdk_version: "current",
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -18,7 +18,7 @@ class App : Application() {
val settingsManager: SettingsManager by lazy { SettingsManager(applicationContext) } val settingsManager: SettingsManager by lazy { SettingsManager(applicationContext) }
val storageBackup: StorageBackup by lazy { val storageBackup: StorageBackup by lazy {
val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() } val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() }
StorageBackup(this, plugin) StorageBackup(this, { plugin })
} }
override fun onCreate() { override fun onCreate() {

View file

@ -98,8 +98,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
fun setBackupLocation(uri: Uri?) { fun setBackupLocation(uri: Uri?) {
if (uri != null) { if (uri != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
storageBackup.deleteAllSnapshots() storageBackup.init()
storageBackup.clearCache()
} }
} }
settingsManager.setBackupLocation(uri) settingsManager.setBackupLocation(uri)

View file

@ -42,12 +42,12 @@ class TestSafStoragePlugin(
} }
@Throws(IOException::class) @Throws(IOException::class)
override fun getChunkOutputStream(chunkId: String): OutputStream { override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
if (getLocationUri() == null) return nullStream if (getLocationUri() == null) return nullStream
return super.getChunkOutputStream(chunkId) return super.getChunkOutputStream(chunkId)
} }
override fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream { override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
if (root == null) return nullStream if (root == null) return nullStream
return super.getBackupSnapshotOutputStream(timestamp) return super.getBackupSnapshotOutputStream(timestamp)
} }

View file

@ -22,6 +22,7 @@ public sealed class SnapshotResult {
public data class StoredSnapshot( public data class StoredSnapshot(
/** /**
* The unique ID of the current device/user combination chosen by the [StoragePlugin]. * The unique ID of the current device/user combination chosen by the [StoragePlugin].
* It may include an '.sv' extension.
*/ */
public val userId: String, public val userId: String,
/** /**

View file

@ -36,10 +36,9 @@ import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "StorageBackup" private const val TAG = "StorageBackup"
@Suppress("BlockingMethodInNonBlockingContext")
public class StorageBackup( public class StorageBackup(
private val context: Context, private val context: Context,
private val plugin: StoragePlugin, private val pluginGetter: () -> StoragePlugin,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) { ) {
@ -50,18 +49,18 @@ public class StorageBackup(
private val uriStore by lazy { db.getUriStore() } private val uriStore by lazy { db.getUriStore() }
private val mediaScanner by lazy { MediaScanner(context) } private val mediaScanner by lazy { MediaScanner(context) }
private val snapshotRetriever = SnapshotRetriever(plugin) private val snapshotRetriever = SnapshotRetriever(pluginGetter)
private val chunksCacheRepopulater = ChunksCacheRepopulater(db, plugin, snapshotRetriever) private val chunksCacheRepopulater = ChunksCacheRepopulater(db, pluginGetter, snapshotRetriever)
private val backup by lazy { private val backup by lazy {
val documentScanner = DocumentScanner(context) val documentScanner = DocumentScanner(context)
val fileScanner = FileScanner(uriStore, mediaScanner, documentScanner) val fileScanner = FileScanner(uriStore, mediaScanner, documentScanner)
Backup(context, db, fileScanner, plugin, chunksCacheRepopulater) Backup(context, db, fileScanner, pluginGetter, chunksCacheRepopulater)
} }
private val restore by lazy { private val restore by lazy {
Restore(context, plugin, snapshotRetriever, FileRestore(context, mediaScanner)) Restore(context, pluginGetter, snapshotRetriever, FileRestore(context, mediaScanner))
} }
private val retention = RetentionManager(context) private val retention = RetentionManager(context)
private val pruner by lazy { Pruner(db, retention, plugin, snapshotRetriever) } private val pruner by lazy { Pruner(db, retention, pluginGetter, snapshotRetriever) }
private val backupRunning = AtomicBoolean(false) private val backupRunning = AtomicBoolean(false)
private val restoreRunning = AtomicBoolean(false) private val restoreRunning = AtomicBoolean(false)
@ -104,6 +103,16 @@ public class StorageBackup(
list.joinToString(", ", limit = 5) list.joinToString(", ", limit = 5)
} }
/**
* Ensures the storage is set-up to receive backups and deletes all snapshots
* (see [deleteAllSnapshots]) as well as clears local cache (see [clearCache]).
*/
public suspend fun init() {
pluginGetter().init()
deleteAllSnapshots()
clearCache()
}
/** /**
* Run this on a new storage location to ensure that there are no old snapshots * Run this on a new storage location to ensure that there are no old snapshots
* (potentially encrypted with an old key) laying around. * (potentially encrypted with an old key) laying around.
@ -113,9 +122,9 @@ public class StorageBackup(
*/ */
public suspend fun deleteAllSnapshots(): Unit = withContext(dispatcher) { public suspend fun deleteAllSnapshots(): Unit = withContext(dispatcher) {
try { try {
plugin.getCurrentBackupSnapshots().forEach { pluginGetter().getCurrentBackupSnapshots().forEach {
try { try {
plugin.deleteBackupSnapshot(it) pluginGetter().deleteBackupSnapshot(it)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error deleting snapshot $it", e) Log.e(TAG, "Error deleting snapshot $it", e)
} }

View file

@ -13,6 +13,13 @@ import javax.crypto.SecretKey
public interface StoragePlugin { public interface StoragePlugin {
/**
* Prepares the storage location for storing backups.
* Call this before using the [StoragePlugin] for the first time.
*/
@Throws(IOException::class)
public suspend fun init()
/** /**
* Called before starting a backup run to ensure that all cached chunks are still available. * Called before starting a backup run to ensure that all cached chunks are still available.
* Plugins should use this opportunity * Plugins should use this opportunity
@ -32,10 +39,10 @@ public interface StoragePlugin {
public fun hasMasterKey(): Boolean public fun hasMasterKey(): Boolean
@Throws(IOException::class) @Throws(IOException::class)
public fun getChunkOutputStream(chunkId: String): OutputStream public suspend fun getChunkOutputStream(chunkId: String): OutputStream
@Throws(IOException::class) @Throws(IOException::class)
public fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream public suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream
/* Restore */ /* Restore */

View file

@ -35,12 +35,11 @@ internal class BackupResult(
) )
} }
@Suppress("BlockingMethodInNonBlockingContext")
internal class Backup( internal class Backup(
private val context: Context, private val context: Context,
private val db: Db, private val db: Db,
private val fileScanner: FileScanner, private val fileScanner: FileScanner,
private val storagePlugin: StoragePlugin, private val storagePluginGetter: () -> StoragePlugin,
private val cacheRepopulater: ChunksCacheRepopulater, private val cacheRepopulater: ChunksCacheRepopulater,
chunkSizeMax: Int = CHUNK_SIZE_MAX, chunkSizeMax: Int = CHUNK_SIZE_MAX,
private val streamCrypto: StreamCrypto = StreamCrypto, private val streamCrypto: StreamCrypto = StreamCrypto,
@ -54,6 +53,7 @@ internal class Backup(
} }
private val contentResolver = context.contentResolver private val contentResolver = context.contentResolver
private val storagePlugin get() = storagePluginGetter()
private val filesCache = db.getFilesCache() private val filesCache = db.getFilesCache()
private val chunksCache = db.getChunksCache() private val chunksCache = db.getChunksCache()

View file

@ -37,7 +37,7 @@ internal class ChunkWriter(
private val buffer = ByteArray(bufferSize) private val buffer = ByteArray(bufferSize)
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
fun writeChunk( suspend fun writeChunk(
inputStream: InputStream, inputStream: InputStream,
chunks: List<Chunk>, chunks: List<Chunk>,
missingChunkIds: List<String>, missingChunkIds: List<String>,
@ -67,7 +67,7 @@ internal class ChunkWriter(
} }
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
private fun writeChunkData(chunkId: String, writer: (OutputStream) -> Unit) { private suspend fun writeChunkData(chunkId: String, writer: (OutputStream) -> Unit) {
storagePlugin.getChunkOutputStream(chunkId).use { chunkStream -> storagePlugin.getChunkOutputStream(chunkId).use { chunkStream ->
chunkStream.write(VERSION.toInt()) chunkStream.write(VERSION.toInt())
val ad = streamCrypto.getAssociatedDataForChunk(chunkId) val ad = streamCrypto.getAssociatedDataForChunk(chunkId)
@ -102,7 +102,7 @@ internal class ChunkWriter(
* @return true if the chunk was written or false, if it was present already. * @return true if the chunk was written or false, if it was present already.
*/ */
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
fun writeZipChunk( suspend fun writeZipChunk(
chunk: ZipChunk, chunk: ZipChunk,
zip: ByteArrayOutputStream, zip: ByteArrayOutputStream,
missingChunkIds: List<String>, missingChunkIds: List<String>,

View file

@ -19,10 +19,9 @@ import kotlin.time.toDuration
private const val TAG = "ChunksCacheRepopulater" private const val TAG = "ChunksCacheRepopulater"
@Suppress("BlockingMethodInNonBlockingContext")
internal class ChunksCacheRepopulater( internal class ChunksCacheRepopulater(
private val db: Db, private val db: Db,
private val storagePlugin: StoragePlugin, private val storagePlugin: () -> StoragePlugin,
private val snapshotRetriever: SnapshotRetriever, private val snapshotRetriever: SnapshotRetriever,
) { ) {
@ -43,7 +42,7 @@ internal class ChunksCacheRepopulater(
availableChunkIds: HashSet<String>, availableChunkIds: HashSet<String>,
) { ) {
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
val snapshots = storagePlugin.getCurrentBackupSnapshots().mapNotNull { storedSnapshot -> val snapshots = storagePlugin().getCurrentBackupSnapshots().mapNotNull { storedSnapshot ->
try { try {
snapshotRetriever.getSnapshot(streamKey, storedSnapshot) snapshotRetriever.getSnapshot(streamKey, storedSnapshot)
} catch (e: GeneralSecurityException) { } catch (e: GeneralSecurityException) {
@ -63,7 +62,7 @@ internal class ChunksCacheRepopulater(
// delete chunks that are not references by any snapshot anymore // delete chunks that are not references by any snapshot anymore
val chunksToDelete = availableChunkIds.subtract(cachedChunks.map { it.id }) val chunksToDelete = availableChunkIds.subtract(cachedChunks.map { it.id })
val deletionDuration = measure { val deletionDuration = measure {
storagePlugin.deleteChunks(chunksToDelete.toList()) storagePlugin().deleteChunks(chunksToDelete.toList())
} }
Log.i(TAG, "Deleting ${chunksToDelete.size} chunks took $deletionDuration") Log.i(TAG, "Deleting ${chunksToDelete.size} chunks took $deletionDuration")
} }

Some files were not shown because too many files have changed in this diff Show more