Merge pull request #647 from grote/webdav
Implement a native WebDAV plugin
This commit is contained in:
commit
d0cf168198
120 changed files with 2658 additions and 638 deletions
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
@ -35,6 +35,10 @@ jobs:
|
|||
cache: 'gradle'
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
NEXTCLOUD_URL: ${{ vars.NEXTCLOUD_URL }}
|
||||
NEXTCLOUD_USER: ${{ secrets.NEXTCLOUD_USER }}
|
||||
NEXTCLOUD_PASS: ${{ secrets.NEXTCLOUD_PASS }}
|
||||
run: ./gradlew compileDebugAndroidTestSources check assemble ktlintCheck
|
||||
|
||||
- name: Upload APKs
|
||||
|
|
|
@ -44,6 +44,10 @@ android_app {
|
|||
"seedvault-lib-koin-android",
|
||||
// bip39
|
||||
"seedvault-lib-kotlin-bip39",
|
||||
// WebDAV
|
||||
"seedvault-lib-dav4jvm",
|
||||
"seedvault-lib-okhttp",
|
||||
"seedvault-lib-okio",
|
||||
],
|
||||
manifest: "app/src/main/AndroidManifest.xml",
|
||||
|
||||
|
|
|
@ -159,6 +159,9 @@ dependencies {
|
|||
|
||||
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)
|
||||
*/
|
||||
|
|
|
@ -41,19 +41,20 @@ class KoinInstrumentationTestApp : App() {
|
|||
|
||||
viewModel {
|
||||
currentRestoreViewModel =
|
||||
spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get()))
|
||||
spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get(), get()))
|
||||
currentRestoreViewModel!!
|
||||
}
|
||||
|
||||
viewModel {
|
||||
currentBackupStorageViewModel =
|
||||
spyk(BackupStorageViewModel(context, get(), get(), get(), get()))
|
||||
val viewModel =
|
||||
BackupStorageViewModel(context, get(), get(), get(), get(), get(), get(), get())
|
||||
currentBackupStorageViewModel = spyk(viewModel)
|
||||
currentBackupStorageViewModel!!
|
||||
}
|
||||
|
||||
viewModel {
|
||||
currentRestoreStorageViewModel =
|
||||
spyk(RestoreStorageViewModel(context, get(), get()))
|
||||
spyk(RestoreStorageViewModel(context, get(), get(), get(), get()))
|
||||
currentRestoreStorageViewModel!!
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.stevesoltys.seedvault
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.test.core.content.pm.PackageInfoBuilder
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
|
@ -28,20 +29,24 @@ import org.koin.core.component.KoinComponent
|
|||
import org.koin.core.component.inject
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
@MediumTest
|
||||
class PluginTest : KoinComponent {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val settingsManager: SettingsManager by inject()
|
||||
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")
|
||||
private val legacyStoragePlugin: LegacyStoragePlugin =
|
||||
DocumentsProviderLegacyPlugin(context, storage)
|
||||
private val legacyStoragePlugin: LegacyStoragePlugin = DocumentsProviderLegacyPlugin(context) {
|
||||
storage
|
||||
}
|
||||
|
||||
private val token = System.currentTimeMillis() - 365L * 24L * 60L * 60L * 1000L
|
||||
private val packageInfo = PackageInfoBuilder.newBuilder().setPackageName("org.example").build()
|
||||
|
@ -49,7 +54,7 @@ class PluginTest : KoinComponent {
|
|||
|
||||
@Before
|
||||
fun setup() = runBlocking {
|
||||
every { mockedSettingsManager.getStorage() } returns settingsManager.getStorage()
|
||||
every { mockedSettingsManager.getSafStorage() } returns settingsManager.getSafStorage()
|
||||
storage.rootBackupDir?.deleteContents(context)
|
||||
?: error("Select a storage location in the app first!")
|
||||
}
|
||||
|
@ -76,8 +81,6 @@ class PluginTest : KoinComponent {
|
|||
fun testInitializationAndRestoreSets() = runBlocking(Dispatchers.IO) {
|
||||
// no backups available initially
|
||||
assertEquals(0, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||
val s = settingsManager.getStorage() ?: error("no storage")
|
||||
assertFalse(storagePlugin.hasBackup(s))
|
||||
|
||||
// prepare returned tokens requested when initializing device
|
||||
every { mockedSettingsManager.getToken() } returnsMany listOf(token, token + 1, token + 1)
|
||||
|
@ -92,7 +95,6 @@ class PluginTest : KoinComponent {
|
|||
|
||||
// one backup available now
|
||||
assertEquals(1, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||
assertTrue(storagePlugin.hasBackup(s))
|
||||
|
||||
// initializing again (with another restore set) does add a restore set
|
||||
storagePlugin.startNewRestoreSet(token + 1)
|
||||
|
@ -100,7 +102,6 @@ class PluginTest : KoinComponent {
|
|||
storagePlugin.getOutputStream(token + 1, FILE_BACKUP_METADATA)
|
||||
.writeAndClose(getRandomByteArray())
|
||||
assertEquals(2, storagePlugin.getAvailableBackups()?.toList()?.size)
|
||||
assertTrue(storagePlugin.hasBackup(s))
|
||||
|
||||
// initializing again (without new restore set) doesn't change number of restore sets
|
||||
storagePlugin.initializeDevice()
|
||||
|
|
|
@ -22,7 +22,6 @@ import com.stevesoltys.seedvault.e2e.screen.impl.DocumentPickerScreen
|
|||
import com.stevesoltys.seedvault.e2e.screen.impl.RecoveryCodeScreen
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
||||
import com.stevesoltys.seedvault.restore.RestoreViewModel
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
|
@ -69,8 +68,6 @@ internal interface LargeTestBase : KoinComponent {
|
|||
|
||||
val keyManager: KeyManager get() = get()
|
||||
|
||||
val documentsStorage: DocumentsStorage get() = get()
|
||||
|
||||
val spyMetadataManager: MetadataManager get() = get()
|
||||
|
||||
val backupManager: IBackupManager get() = get()
|
||||
|
@ -84,7 +81,6 @@ internal interface LargeTestBase : KoinComponent {
|
|||
fun resetApplicationState() {
|
||||
backupManager.setAutoRestore(false)
|
||||
settingsManager.setNewToken(null)
|
||||
documentsStorage.reset(null)
|
||||
|
||||
val sharedPreferences = permitDiskReads {
|
||||
PreferenceManager.getDefaultSharedPreferences(targetContext)
|
||||
|
|
|
@ -17,7 +17,7 @@ internal class BackupRestoreTest : SeedvaultLargeTest() {
|
|||
confirmCode()
|
||||
}
|
||||
|
||||
if (settingsManager.getStorage() == null) {
|
||||
if (settingsManager.getSafStorage() == null) {
|
||||
chooseStorageLocation()
|
||||
} else {
|
||||
changeBackupLocation()
|
||||
|
|
|
@ -39,13 +39,16 @@ import java.io.IOException
|
|||
import kotlin.random.Random
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
@MediumTest
|
||||
class DocumentsStorageTest : KoinComponent {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
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 lateinit var file: DocumentFile
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.util.Log
|
|||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.settings.AppStatus
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import io.mockk.every
|
||||
|
@ -24,7 +25,9 @@ class PackageServiceTest : KoinComponent {
|
|||
|
||||
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
|
||||
fun testNotAllowedPackages() {
|
||||
|
|
|
@ -16,6 +16,9 @@
|
|||
<!-- This is needed to check for internet access when backup is stored on network storage -->
|
||||
<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 -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
|
@ -97,7 +100,8 @@
|
|||
|
||||
<activity
|
||||
android:name=".ui.storage.StorageActivity"
|
||||
android:theme="@style/AppTheme.NoActionBar" />
|
||||
android:theme="@style/AppTheme.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.storage.PermissionGrantActivity"
|
||||
|
|
|
@ -13,13 +13,15 @@ import android.os.StrictMode
|
|||
import android.os.UserHandle
|
||||
import android.os.UserManager
|
||||
import android.provider.Settings
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||
import androidx.work.WorkManager
|
||||
import com.stevesoltys.seedvault.crypto.cryptoModule
|
||||
import com.stevesoltys.seedvault.header.headerModule
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
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.install.installModule
|
||||
import com.stevesoltys.seedvault.settings.AppListRetriever
|
||||
|
@ -54,15 +56,41 @@ open class App : Application() {
|
|||
private val appModule = module {
|
||||
single { SettingsManager(this@App) }
|
||||
single { BackupNotificationManager(this@App) }
|
||||
single { StoragePluginManager(this@App, get(), get(), get()) }
|
||||
single { BackupStateManager(this@App) }
|
||||
single { Clock() }
|
||||
factory<IBackupManager> { IBackupManager.Stub.asInterface(getService(BACKUP_SERVICE)) }
|
||||
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 { BackupStorageViewModel(this@App, get(), get(), get(), get()) }
|
||||
viewModel { RestoreStorageViewModel(this@App, get(), get()) }
|
||||
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get()) }
|
||||
viewModel {
|
||||
BackupStorageViewModel(
|
||||
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()) }
|
||||
}
|
||||
|
||||
|
@ -100,7 +128,8 @@ open class App : Application() {
|
|||
cryptoModule,
|
||||
headerModule,
|
||||
metadataModule,
|
||||
documentsProviderModule, // storage plugin
|
||||
storagePluginModuleSaf,
|
||||
storagePluginModuleWebDav,
|
||||
backupModule,
|
||||
restoreModule,
|
||||
installModule,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -1,13 +1,17 @@
|
|||
package com.stevesoltys.seedvault.plugins
|
||||
|
||||
import android.app.backup.RestoreSet
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.settings.Storage
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
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.
|
||||
|
@ -47,14 +51,6 @@ interface StoragePlugin {
|
|||
@Throws(IOException::class)
|
||||
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.
|
||||
*
|
||||
|
@ -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}")
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -10,12 +10,13 @@ import java.io.IOException
|
|||
import java.io.InputStream
|
||||
|
||||
@WorkerThread
|
||||
@Suppress("BlockingMethodInNonBlockingContext", "Deprecation") // all methods do I/O
|
||||
@Suppress("Deprecation")
|
||||
internal class DocumentsProviderLegacyPlugin(
|
||||
private val context: Context,
|
||||
private val storage: DocumentsStorage,
|
||||
private val storageGetter: () -> DocumentsStorage,
|
||||
) : LegacyStoragePlugin {
|
||||
|
||||
private val storage get() = storageGetter()
|
||||
private var packageDir: DocumentFile? = null
|
||||
private var packageChildren: List<DocumentFile>? = null
|
||||
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
package com.stevesoltys.seedvault.plugins.saf
|
||||
|
||||
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.dsl.module
|
||||
|
||||
val documentsProviderModule = module {
|
||||
single { DocumentsStorage(androidContext(), get()) }
|
||||
val storagePluginModuleSaf = module {
|
||||
single { SafFactory(androidContext(), get(), get()) }
|
||||
single { SafHandler(androidContext(), get(), get(), get()) }
|
||||
|
||||
single<StoragePlugin> { DocumentsProviderStoragePlugin(androidContext(), get()) }
|
||||
@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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,15 @@ package com.stevesoltys.seedvault.plugins.saf
|
|||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.stevesoltys.seedvault.getStorageContext
|
||||
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.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.IOException
|
||||
import java.io.InputStream
|
||||
|
@ -15,22 +18,23 @@ import java.io.OutputStream
|
|||
|
||||
private val TAG = DocumentsProviderStoragePlugin::class.java.simpleName
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class DocumentsProviderStoragePlugin(
|
||||
private val appContext: Context,
|
||||
private val storage: DocumentsStorage,
|
||||
) : StoragePlugin {
|
||||
) : StoragePlugin<Uri> {
|
||||
|
||||
/**
|
||||
* Attention: This context might be from a different user. Use with care.
|
||||
*/
|
||||
private val context: Context
|
||||
get() = appContext.getStorageContext {
|
||||
storage.storage?.isUsb == true
|
||||
}
|
||||
private val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
|
||||
|
||||
private val packageManager: PackageManager = appContext.packageManager
|
||||
|
||||
override suspend fun test(): Boolean {
|
||||
val dir = storage.rootBackupDir
|
||||
return dir != null && dir.exists()
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override suspend fun startNewRestoreSet(token: Long) {
|
||||
// reset current storage
|
||||
|
@ -71,16 +75,6 @@ internal class DocumentsProviderStoragePlugin(
|
|||
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>? {
|
||||
val rootDir = storage.rootBackupDir ?: return null
|
||||
val backupSets = getBackups(context, rootDir)
|
||||
|
@ -104,7 +98,6 @@ internal class DocumentsProviderStoragePlugin(
|
|||
|
||||
class BackupSet(val token: Long, val metadataFile: DocumentFile)
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<BackupSet> {
|
||||
val backupSets = ArrayList<BackupSet>()
|
||||
val files = try {
|
||||
|
@ -137,9 +130,6 @@ internal suspend fun getBackups(context: Context, rootDir: DocumentFile): List<B
|
|||
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? {
|
||||
val looksLikeToken = name != null && tokenRegex.matches(name)
|
||||
// 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 {
|
||||
return name != FILE_NO_MEDIA &&
|
||||
!chunkFolderRegex.matches(name) &&
|
||||
!name.endsWith(".SeedSnap")
|
||||
!name.endsWith(SNAPSHOT_EXT)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
@file:Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
||||
package com.stevesoltys.seedvault.plugins.saf
|
||||
|
||||
import android.content.ContentResolver
|
||||
|
@ -18,7 +16,6 @@ import androidx.annotation.VisibleForTesting
|
|||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.stevesoltys.seedvault.getStorageContext
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.settings.Storage
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
@ -44,27 +41,19 @@ private val TAG = DocumentsStorage::class.java.simpleName
|
|||
internal class DocumentsStorage(
|
||||
private val appContext: Context,
|
||||
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.
|
||||
*/
|
||||
private val context: Context
|
||||
get() = appContext.getStorageContext {
|
||||
storage?.isUsb == true
|
||||
}
|
||||
private val context: Context get() = appContext.getStorageContext { safStorage.isUsb }
|
||||
private val contentResolver: ContentResolver get() = context.contentResolver
|
||||
|
||||
internal var rootBackupDir: DocumentFile? = null
|
||||
get() = runBlocking {
|
||||
if (field == null) {
|
||||
val parent = storage?.getDocumentFile(context)
|
||||
?: return@runBlocking null
|
||||
val parent = safStorage.getDocumentFile(context)
|
||||
field = try {
|
||||
parent.createOrGetDirectory(context, DIRECTORY_ROOT).apply {
|
||||
// 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.
|
||||
*/
|
||||
fun reset(newToken: Long?) {
|
||||
storage = null
|
||||
currentToken = newToken
|
||||
rootBackupDir = null
|
||||
currentSetDir = null
|
||||
}
|
||||
|
||||
fun getAuthority(): String? = storage?.uri?.authority
|
||||
fun getAuthority(): String? = safStorage.uri.authority
|
||||
|
||||
@Throws(IOException::class)
|
||||
suspend fun getSetDir(token: Long = currentToken ?: error("no token")): DocumentFile? {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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.Intent
|
||||
|
@ -9,8 +14,13 @@ import android.provider.DocumentsContract
|
|||
import android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
||||
import android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID
|
||||
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.StorageRootResolver.getIcon
|
||||
|
||||
private const val DAVX5_PACKAGE = "at.bitfire.davdroid"
|
||||
private const val DAVX5_ACTIVITY = "at.bitfire.davdroid.ui.webdav.WebdavMountsActivity"
|
||||
|
@ -29,15 +39,15 @@ internal class SafStorageOptions(
|
|||
|
||||
private val packageManager = context.packageManager
|
||||
|
||||
internal fun checkOrAddExtraRoots(roots: ArrayList<SafOption>) {
|
||||
internal fun checkOrAddExtraRoots(roots: ArrayList<StorageOption>) {
|
||||
checkOrAddUsbRoot(roots)
|
||||
checkOrAddDavX5Root(roots)
|
||||
checkOrAddNextCloudRoot(roots)
|
||||
checkOrAddRoundSyncRoots(roots)
|
||||
}
|
||||
|
||||
private fun checkOrAddUsbRoot(roots: ArrayList<SafOption>) {
|
||||
if (doNotInclude(AUTHORITY_STORAGE, roots) { it.isUsb }) return
|
||||
private fun checkOrAddUsbRoot(roots: ArrayList<StorageOption>) {
|
||||
if (doNotInclude(AUTHORITY_STORAGE, roots) { it is SafOption && it.isUsb }) return
|
||||
|
||||
val root = SafOption(
|
||||
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.
|
||||
*/
|
||||
private fun checkOrAddRoundSyncRoots(roots: ArrayList<SafOption>) {
|
||||
private fun checkOrAddRoundSyncRoots(roots: ArrayList<StorageOption>) {
|
||||
|
||||
val roundSyncRoot = roots.firstOrNull {
|
||||
it.authority == AUTHORITY_ROUND_SYNC
|
||||
} ?: return
|
||||
it is SafOption && it.authority == AUTHORITY_ROUND_SYNC
|
||||
} as? SafOption ?: return
|
||||
|
||||
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.
|
||||
*/
|
||||
private fun checkOrAddDavX5Root(roots: ArrayList<SafOption>) {
|
||||
private fun checkOrAddDavX5Root(roots: ArrayList<StorageOption>) {
|
||||
if (doNotInclude(AUTHORITY_DAVX5, roots)) return
|
||||
|
||||
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
|
||||
* (which hides existing accounts).
|
||||
*/
|
||||
private fun checkOrAddNextCloudRoot(roots: ArrayList<SafOption>) {
|
||||
private fun checkOrAddNextCloudRoot(roots: ArrayList<StorageOption>) {
|
||||
if (doNotInclude(AUTHORITY_NEXTCLOUD, roots)) return
|
||||
|
||||
val intent = Intent().apply {
|
||||
|
@ -202,11 +212,12 @@ internal class SafStorageOptions(
|
|||
|
||||
private fun doNotInclude(
|
||||
authority: String,
|
||||
roots: ArrayList<SafOption>,
|
||||
doNotIncludeIfTrue: ((SafOption) -> Boolean)? = null,
|
||||
roots: ArrayList<StorageOption>,
|
||||
doNotIncludeIfTrue: ((StorageOption) -> Boolean)? = null,
|
||||
): Boolean {
|
||||
if (!isAuthoritySupported(authority)) return true
|
||||
for (root in roots) {
|
||||
if (root !is SafOption) continue
|
||||
if (root.authority == authority && doNotIncludeIfTrue?.invoke(root) != false) {
|
||||
return true
|
||||
}
|
|
@ -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.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_IS_CHILD
|
||||
import android.util.Log
|
||||
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||
import com.stevesoltys.seedvault.R
|
||||
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
|
||||
|
||||
internal object StorageRootResolver {
|
||||
|
@ -126,23 +139,23 @@ internal object StorageRootResolver {
|
|||
fun getIcon(context: Context, authority: String, rootId: String, icon: Int): Drawable? {
|
||||
return getPackageIcon(context, authority, icon) ?: when {
|
||||
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 -> {
|
||||
context.getDrawable(R.drawable.ic_usb)
|
||||
getDrawable(context, R.drawable.ic_usb)
|
||||
}
|
||||
|
||||
authority == AUTHORITY_NEXTCLOUD -> {
|
||||
context.getDrawable(R.drawable.nextcloud)
|
||||
getDrawable(context, R.drawable.nextcloud)
|
||||
}
|
||||
|
||||
authority == AUTHORITY_DAVX5 -> {
|
||||
context.getDrawable(R.drawable.davx5)
|
||||
getDrawable(context, R.drawable.davx5)
|
||||
}
|
||||
|
||||
authority == AUTHORITY_ROUND_SYNC -> {
|
||||
context.getDrawable(R.drawable.round_sync)
|
||||
getDrawable(context, R.drawable.round_sync)
|
||||
}
|
||||
|
||||
else -> null
|
|
@ -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,
|
||||
)
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -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()) }
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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.UNKNOWN_ERROR
|
||||
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_BACKUP
|
||||
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
|
||||
|
@ -81,8 +82,9 @@ internal class RestoreViewModel(
|
|||
private val restoreCoordinator: RestoreCoordinator,
|
||||
private val apkRestore: ApkRestore,
|
||||
storageBackup: StorageBackup,
|
||||
pluginManager: StoragePluginManager,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager),
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager),
|
||||
RestorableBackupClickListener, SnapshotViewModel {
|
||||
|
||||
override val isRestoreOperation = true
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.stevesoltys.seedvault.metadata.ApkSplit
|
|||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
||||
|
@ -28,7 +29,7 @@ private val TAG = ApkRestore::class.java.simpleName
|
|||
|
||||
internal class ApkRestore(
|
||||
private val context: Context,
|
||||
private val storagePlugin: StoragePlugin,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
@Suppress("Deprecation")
|
||||
private val legacyStoragePlugin: LegacyStoragePlugin,
|
||||
private val crypto: Crypto,
|
||||
|
@ -37,6 +38,7 @@ internal class ApkRestore(
|
|||
) {
|
||||
|
||||
private val pm = context.packageManager
|
||||
private val storagePlugin get() = pluginManager.appPlugin
|
||||
|
||||
fun restore(backup: RestorableBackup) = flow {
|
||||
// we don't filter out apps without APK, so the user can manually install them
|
||||
|
@ -87,7 +89,7 @@ internal class ApkRestore(
|
|||
emit(installResult)
|
||||
}
|
||||
|
||||
@Suppress("ThrowsCount", "BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
|
||||
@Suppress("ThrowsCount")
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
private suspend fun restore(
|
||||
collector: FlowCollector<InstallResult>,
|
||||
|
@ -212,7 +214,6 @@ internal class ApkRestore(
|
|||
* @return a [Pair] of the cached [File] and SHA-256 hash.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
|
||||
private suspend fun cacheApk(
|
||||
version: Byte,
|
||||
token: Long,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -10,6 +10,7 @@ import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
|||
import androidx.work.ExistingPeriodicWorkPolicy.UPDATE
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
|
||||
|
||||
|
@ -18,6 +19,7 @@ class SchedulingFragment : PreferenceFragmentCompat(),
|
|||
|
||||
private val viewModel: SettingsViewModel by sharedViewModel()
|
||||
private val settingsManager: SettingsManager by inject()
|
||||
private val storagePluginManager: StoragePluginManager by inject()
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
permitDiskReads {
|
||||
|
@ -29,7 +31,7 @@ class SchedulingFragment : PreferenceFragmentCompat(),
|
|||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val storage = settingsManager.getStorage()
|
||||
val storage = storagePluginManager.storageProperties
|
||||
if (storage?.isUsb == true) {
|
||||
findPreference<PreferenceCategory>("scheduling_category_conditions")?.isEnabled = false
|
||||
}
|
||||
|
|
|
@ -22,6 +22,8 @@ import androidx.preference.TwoStatePreference
|
|||
import androidx.work.WorkInfo
|
||||
import com.stevesoltys.seedvault.R
|
||||
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.ui.toRelativeTime
|
||||
import org.koin.android.ext.android.inject
|
||||
|
@ -33,7 +35,7 @@ private val TAG = SettingsFragment::class.java.name
|
|||
class SettingsFragment : PreferenceFragmentCompat() {
|
||||
|
||||
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 lateinit var backup: TwoStatePreference
|
||||
|
@ -48,7 +50,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
private var menuBackupNow: 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?) {
|
||||
permitDiskReads {
|
||||
|
@ -88,8 +91,14 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
|
||||
backupLocation = findPreference("backup_location")!!
|
||||
backupLocation.setOnPreferenceClickListener {
|
||||
viewModel.chooseBackupLocation()
|
||||
true
|
||||
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()
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
autoRestore = findPreference(PREF_KEY_AUTO_RESTORE)!!
|
||||
|
@ -148,7 +157,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
setAppBackupStatusSummary(time)
|
||||
}
|
||||
viewModel.appBackupWorkInfo.observe(viewLifecycleOwner) { workInfo ->
|
||||
viewModel.onWorkerStateChanged()
|
||||
setAppBackupSchedulingSummary(workInfo)
|
||||
}
|
||||
|
||||
|
@ -164,7 +172,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
// we need to re-set the title when returning to this fragment
|
||||
activity?.setTitle(R.string.backup)
|
||||
|
||||
storage = settingsManager.getStorage()
|
||||
setBackupEnabledState()
|
||||
setBackupLocationSummary()
|
||||
setAutoRestoreState()
|
||||
|
@ -241,7 +248,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
activity?.contentResolver?.let {
|
||||
autoRestore.isChecked = Settings.Secure.getInt(it, BACKUP_AUTO_RESTORE, 1) == 1
|
||||
}
|
||||
val storage = this.storage
|
||||
val storage = this.storageProperties
|
||||
if (storage?.isUsb == true) {
|
||||
autoRestore.summary = getString(R.string.settings_auto_restore_summary) + "\n\n" +
|
||||
getString(R.string.settings_auto_restore_summary_usb, storage.name)
|
||||
|
@ -252,7 +259,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
|
||||
private fun setBackupLocationSummary() {
|
||||
// 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?) {
|
||||
|
@ -271,7 +279,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
* says that nothing is scheduled which can happen when backup destination is on flash drive.
|
||||
*/
|
||||
private fun setAppBackupSchedulingSummary(workInfo: WorkInfo?) {
|
||||
if (storage?.isUsb == true) {
|
||||
if (storageProperties?.isUsb == true) {
|
||||
backupScheduling.summary = getString(R.string.settings_backup_status_next_backup_usb)
|
||||
return
|
||||
}
|
||||
|
|
|
@ -3,15 +3,17 @@ package com.stevesoltys.seedvault.settings
|
|||
import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.stevesoltys.seedvault.getStorageContext
|
||||
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 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_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_NAME = "storageName"
|
||||
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_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_STORAGE = "backup_storage"
|
||||
|
@ -88,24 +101,55 @@ class SettingsManager(private val context: Context) {
|
|||
token = newToken
|
||||
}
|
||||
|
||||
// FIXME Storage is currently plugin specific and not generic
|
||||
fun setStorage(storage: Storage) {
|
||||
internal val storagePluginType: StoragePluginType?
|
||||
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()
|
||||
.putString(PREF_KEY_STORAGE_PLUGIN, StoragePluginType.SAF.name)
|
||||
.apply()
|
||||
StoragePluginType.SAF
|
||||
} 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_URI, storage.uri.toString())
|
||||
.putString(PREF_KEY_STORAGE_NAME, storage.name)
|
||||
.putBoolean(PREF_KEY_STORAGE_IS_USB, storage.isUsb)
|
||||
.putBoolean(PREF_KEY_STORAGE_REQUIRES_NETWORK, storage.requiresNetwork)
|
||||
.putString(PREF_KEY_STORAGE_PLUGIN, value)
|
||||
.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 uri = Uri.parse(uriStr)
|
||||
val name = prefs.getString(PREF_KEY_STORAGE_NAME, null)
|
||||
?: throw IllegalStateException("no storage name")
|
||||
val isUsb = prefs.getBoolean(PREF_KEY_STORAGE_IS_USB, 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?) {
|
||||
|
@ -134,20 +178,22 @@ class SettingsManager(private val context: Context) {
|
|||
return FlashDrive(name, serialNumber, vendorId, productId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = getStorage() ?: return false
|
||||
val systemContext = context.getStorageContext { storage.isUsb }
|
||||
return !storage.isUnavailableUsb(systemContext) &&
|
||||
!storage.isUnavailableNetwork(context, useMeteredNetwork)
|
||||
val webDavProperties: WebDavProperties?
|
||||
get() {
|
||||
val config = WebDavConfig(
|
||||
url = prefs.getString(PREF_KEY_WEBDAV_URL, null) ?: return null,
|
||||
username = prefs.getString(PREF_KEY_WEBDAV_USER, null) ?: return null,
|
||||
password = prefs.getString(PREF_KEY_WEBDAV_PASS, null) ?: return null,
|
||||
)
|
||||
return createWebDavProperties(context, config)
|
||||
}
|
||||
|
||||
fun saveWebDavConfig(config: WebDavConfig) {
|
||||
prefs.edit()
|
||||
.putString(PREF_KEY_WEBDAV_URL, config.url)
|
||||
.putString(PREF_KEY_WEBDAV_USER, config.username)
|
||||
.putString(PREF_KEY_WEBDAV_PASS, config.password)
|
||||
.apply()
|
||||
}
|
||||
|
||||
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(
|
||||
val name: String,
|
||||
val serialNumber: String?,
|
||||
|
|
|
@ -28,12 +28,14 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.recyclerview.widget.DiffUtil.calculateDiff
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import com.stevesoltys.seedvault.BackupStateManager
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
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.StorageBackupService
|
||||
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.Companion.UNIQUE_WORK_NAME
|
||||
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.withContext
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
|
@ -59,12 +64,14 @@ internal class SettingsViewModel(
|
|||
app: Application,
|
||||
settingsManager: SettingsManager,
|
||||
keyManager: KeyManager,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
private val metadataManager: MetadataManager,
|
||||
private val appListRetriever: AppListRetriever,
|
||||
private val storageBackup: StorageBackup,
|
||||
private val backupManager: IBackupManager,
|
||||
private val backupInitializer: BackupInitializer,
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager) {
|
||||
backupStateManager: BackupStateManager,
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager) {
|
||||
|
||||
private val contentResolver = app.contentResolver
|
||||
private val connectivityManager: ConnectivityManager? =
|
||||
|
@ -73,6 +80,7 @@ internal class SettingsViewModel(
|
|||
|
||||
override val isRestoreOperation = false
|
||||
|
||||
val isBackupRunning: StateFlow<Boolean>
|
||||
private val mBackupPossible = MutableLiveData(false)
|
||||
val backupPossible: LiveData<Boolean> = mBackupPossible
|
||||
|
||||
|
@ -91,11 +99,11 @@ internal class SettingsViewModel(
|
|||
private val mAppEditMode = MutableLiveData<Boolean>()
|
||||
internal val appEditMode: LiveData<Boolean> = mAppEditMode
|
||||
|
||||
private val _filesSummary = MutableLiveData<String>()
|
||||
internal val filesSummary: LiveData<String> = _filesSummary
|
||||
private val mFilesSummary = MutableLiveData<String>()
|
||||
internal val filesSummary: LiveData<String> = mFilesSummary
|
||||
|
||||
private val _initEvent = MutableLiveEvent<Boolean>()
|
||||
val initEvent: LiveEvent<Boolean> = _initEvent
|
||||
private val mInitEvent = MutableLiveEvent<Boolean>()
|
||||
val initEvent: LiveEvent<Boolean> = mInitEvent
|
||||
|
||||
private val storageObserver = object : ContentObserver(null) {
|
||||
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
|
||||
viewModelScope
|
||||
}
|
||||
isBackupRunning = backupStateManager.isBackupRunning.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = false,
|
||||
)
|
||||
scope.launch {
|
||||
// ensures the lastBackupTime LiveData gets set
|
||||
metadataManager.getLastBackupTime()
|
||||
// update running state
|
||||
isBackupRunning.collect {
|
||||
onBackupRunningStateChanged()
|
||||
}
|
||||
}
|
||||
onStoragePropertiesChanged()
|
||||
loadFilesSummary()
|
||||
}
|
||||
|
||||
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) {
|
||||
// disable storage backup if new storage is on USB
|
||||
cancelAppBackup()
|
||||
|
@ -147,26 +164,29 @@ internal class SettingsViewModel(
|
|||
onStoragePropertiesChanged()
|
||||
}
|
||||
|
||||
fun onWorkerStateChanged() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val canDo = settingsManager.canDoBackupNow() &&
|
||||
appBackupWorkInfo.value?.state != WorkInfo.State.RUNNING
|
||||
private fun onBackupRunningStateChanged() {
|
||||
if (isBackupRunning.value) mBackupPossible.postValue(false)
|
||||
else viewModelScope.launch(Dispatchers.IO) {
|
||||
val canDo = !isBackupRunning.value && !pluginManager.isOnUnavailableUsb()
|
||||
mBackupPossible.postValue(canDo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onStoragePropertiesChanged() {
|
||||
val storage = settingsManager.getStorage() ?: return
|
||||
val storage = pluginManager.storageProperties ?: return
|
||||
|
||||
Log.d(TAG, "onStoragePropertiesChanged")
|
||||
// register storage observer
|
||||
try {
|
||||
contentResolver.unregisterContentObserver(storageObserver)
|
||||
contentResolver.registerContentObserver(storage.uri, false, storageObserver)
|
||||
} catch (e: SecurityException) {
|
||||
// This can happen if the app providing the storage was uninstalled.
|
||||
// validLocationIsSet() gets called elsewhere and prompts for a new storage location.
|
||||
Log.e(TAG, "Error registering content observer for ${storage.uri}", e)
|
||||
if (storage is SafStorage) {
|
||||
// register storage observer
|
||||
try {
|
||||
contentResolver.unregisterContentObserver(storageObserver)
|
||||
contentResolver.registerContentObserver(storage.uri, false, storageObserver)
|
||||
} catch (e: SecurityException) {
|
||||
// This can happen if the app providing the storage was uninstalled.
|
||||
// validLocationIsSet() gets called elsewhere
|
||||
// and prompts for a new storage location.
|
||||
Log.e(TAG, "Error registering content observer for ${storage.uri}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// register network observer if needed
|
||||
|
@ -174,6 +194,7 @@ internal class SettingsViewModel(
|
|||
connectivityManager?.unregisterNetworkCallback(networkCallback)
|
||||
networkCallback.registered = false
|
||||
} 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()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
@ -181,7 +202,7 @@ internal class SettingsViewModel(
|
|||
networkCallback.registered = true
|
||||
}
|
||||
// update whether we can do backups right now or not
|
||||
onWorkerStateChanged()
|
||||
onBackupRunningStateChanged()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
@ -200,8 +221,7 @@ internal class SettingsViewModel(
|
|||
i.putExtra(EXTRA_START_APP_BACKUP, true)
|
||||
startForegroundService(app, i)
|
||||
} else {
|
||||
val isUsb = settingsManager.getStorage()?.isUsb ?: false
|
||||
AppBackupWorker.scheduleNow(app, reschedule = !isUsb)
|
||||
AppBackupWorker.scheduleNow(app, reschedule = !pluginManager.isOnRemovableDrive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -232,7 +252,7 @@ internal class SettingsViewModel(
|
|||
@UiThread
|
||||
fun loadFilesSummary() = viewModelScope.launch {
|
||||
val uriSummary = storageBackup.getUriSummaryString()
|
||||
_filesSummary.value = uriSummary.ifEmpty {
|
||||
mFilesSummary.value = uriSummary.ifEmpty {
|
||||
app.getString(R.string.settings_backup_files_summary)
|
||||
}
|
||||
}
|
||||
|
@ -248,10 +268,10 @@ internal class SettingsViewModel(
|
|||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
backupInitializer.initialize(onError) {
|
||||
_initEvent.postEvent(false)
|
||||
mInitEvent.postEvent(false)
|
||||
scheduleAppBackup(CANCEL_AND_REENQUEUE)
|
||||
}
|
||||
_initEvent.postEvent(true)
|
||||
mInitEvent.postEvent(true)
|
||||
}
|
||||
}
|
||||
// enable call log backups for existing installs (added end of 2020)
|
||||
|
@ -280,20 +300,19 @@ internal class SettingsViewModel(
|
|||
}
|
||||
|
||||
fun scheduleAppBackup(existingWorkPolicy: ExistingPeriodicWorkPolicy) {
|
||||
val storage = settingsManager.getStorage() ?: error("no storage available")
|
||||
if (!storage.isUsb && backupManager.isBackupEnabled) {
|
||||
if (!pluginManager.isOnRemovableDrive && backupManager.isBackupEnabled) {
|
||||
AppBackupWorker.schedule(app, settingsManager, existingWorkPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleFilesBackup() {
|
||||
val storage = settingsManager.getStorage() ?: error("no storage available")
|
||||
if (!storage.isUsb && settingsManager.isStorageBackupEnabled()) {
|
||||
if (!pluginManager.isOnRemovableDrive && settingsManager.isStorageBackupEnabled()) {
|
||||
val requiresNetwork = pluginManager.storageProperties?.requiresNetwork == true
|
||||
BackupJobService.scheduleJob(
|
||||
context = app,
|
||||
jobServiceClass = StorageBackupJobService::class.java,
|
||||
periodMillis = HOURS.toMillis(24),
|
||||
networkType = if (storage.requiresNetwork) NETWORK_TYPE_UNMETERED
|
||||
networkType = if (requiresNetwork) NETWORK_TYPE_UNMETERED
|
||||
else NETWORK_TYPE_NONE,
|
||||
deviceIdle = false,
|
||||
charging = true
|
||||
|
@ -301,7 +320,7 @@ internal class SettingsViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun cancelAppBackup() {
|
||||
private fun cancelAppBackup() {
|
||||
AppBackupWorker.unschedule(app)
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import com.stevesoltys.seedvault.plugins.saf.DocumentsStorage
|
|||
import org.calyxos.backup.storage.plugin.saf.SafStoragePlugin
|
||||
import javax.crypto.SecretKey
|
||||
|
||||
internal class SeedvaultStoragePlugin(
|
||||
internal class SeedvaultSafStoragePlugin(
|
||||
private val appContext: Context,
|
||||
private val storage: DocumentsStorage,
|
||||
private val keyManager: KeyManager,
|
||||
|
@ -16,12 +16,8 @@ internal class SeedvaultStoragePlugin(
|
|||
/**
|
||||
* Attention: This context might be from a different user. Use with care.
|
||||
*/
|
||||
override val context: Context
|
||||
get() = appContext.getStorageContext {
|
||||
storage.storage?.isUsb == true
|
||||
}
|
||||
override val root: DocumentFile
|
||||
get() = storage.rootBackupDir ?: error("No storage set")
|
||||
override val context: Context get() = appContext.getStorageContext { storage.safStorage.isUsb }
|
||||
override val root: DocumentFile get() = storage.rootBackupDir ?: error("No storage set")
|
||||
|
||||
override fun getMasterKey(): SecretKey = keyManager.getMainKey()
|
||||
override fun hasMasterKey(): Boolean = keyManager.hasMainKey()
|
|
@ -1,8 +1,10 @@
|
|||
package com.stevesoltys.seedvault.storage
|
||||
|
||||
import android.content.Intent
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
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.RestoreObserver
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
|
@ -31,19 +33,31 @@ internal class StorageBackupService : BackupService() {
|
|||
|
||||
companion object {
|
||||
internal const val EXTRA_START_APP_BACKUP = "startAppBackup"
|
||||
private val mIsRunning = MutableStateFlow(false)
|
||||
val isRunning = mIsRunning.asStateFlow()
|
||||
}
|
||||
|
||||
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
|
||||
override val backupObserver: BackupObserver by lazy {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
package com.stevesoltys.seedvault.storage
|
||||
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.api.StoragePlugin
|
||||
import org.koin.dsl.module
|
||||
|
||||
val storageModule = module {
|
||||
single<StoragePlugin> { SeedvaultStoragePlugin(get(), get(), get()) }
|
||||
single { StorageBackup(get(), get()) }
|
||||
single { StorageBackup(get(), { get<StoragePluginManager>().filesPlugin }) }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@ import android.os.IBinder
|
|||
import android.util.Log
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
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.inject
|
||||
|
||||
|
@ -18,6 +20,11 @@ private val TAG = ConfigurableBackupTransportService::class.java.simpleName
|
|||
*/
|
||||
class ConfigurableBackupTransportService : Service(), KoinComponent {
|
||||
|
||||
companion object {
|
||||
private val mIsRunning = MutableStateFlow(false)
|
||||
val isRunning = mIsRunning.asStateFlow()
|
||||
}
|
||||
|
||||
private var transport: ConfigurableBackupTransport? = null
|
||||
|
||||
private val keyManager: KeyManager by inject()
|
||||
|
@ -27,6 +34,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
transport = ConfigurableBackupTransport(applicationContext)
|
||||
mIsRunning.value = true
|
||||
Log.d(TAG, "Service created.")
|
||||
}
|
||||
|
||||
|
@ -47,6 +55,7 @@ class ConfigurableBackupTransportService : Service(), KoinComponent {
|
|||
super.onDestroy()
|
||||
notificationManager.onServiceDestroyed()
|
||||
transport = null
|
||||
mIsRunning.value = false
|
||||
Log.d(TAG, "Service destroyed.")
|
||||
}
|
||||
|
||||
|
|
|
@ -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.UNKNOWN_ERROR
|
||||
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.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
|
@ -54,11 +55,10 @@ private class CoordinatorState(
|
|||
* @author Steve Soltys
|
||||
* @author Torsten Grote
|
||||
*/
|
||||
@WorkerThread // entire class should always be accessed from a worker thread, so blocking is ok
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
@WorkerThread
|
||||
internal class BackupCoordinator(
|
||||
private val context: Context,
|
||||
private val plugin: StoragePlugin,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
private val kv: KVBackup,
|
||||
private val full: FullBackup,
|
||||
private val clock: Clock,
|
||||
|
@ -68,6 +68,7 @@ internal class BackupCoordinator(
|
|||
private val nm: BackupNotificationManager,
|
||||
) {
|
||||
|
||||
private val plugin get() = pluginManager.appPlugin
|
||||
private val state = CoordinatorState(
|
||||
calledInitialize = false,
|
||||
calledClearBackupData = false,
|
||||
|
@ -126,7 +127,7 @@ internal class BackupCoordinator(
|
|||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error initializing device", e)
|
||||
// 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
|
||||
}
|
||||
|
||||
|
@ -354,7 +355,7 @@ internal class BackupCoordinator(
|
|||
if (result == TRANSPORT_OK) {
|
||||
val isNormalBackup = packageName != MAGIC_PACKAGE_MANAGER
|
||||
// call onPackageBackedUp for @pm@ only if we can do backups right now
|
||||
if (isNormalBackup || settingsManager.canDoBackupNow()) {
|
||||
if (isNormalBackup || pluginManager.canDoBackupNow()) {
|
||||
try {
|
||||
onPackageBackedUp(packageInfo, BackupType.KV, size)
|
||||
} catch (e: Exception) {
|
||||
|
@ -411,7 +412,7 @@ internal class BackupCoordinator(
|
|||
val longBackoff = DAYS.toMillis(30)
|
||||
|
||||
// back off if there's no storage set
|
||||
val storage = settingsManager.getStorage() ?: return longBackoff
|
||||
val storage = pluginManager.storageProperties ?: return longBackoff
|
||||
return when {
|
||||
// back off if storage is removable and not available right now
|
||||
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")
|
||||
return getOutputStream(t, FILE_BACKUP_METADATA)
|
||||
}
|
||||
|
|
|
@ -11,38 +11,38 @@ val backupModule = module {
|
|||
context = androidContext(),
|
||||
backupManager = get(),
|
||||
settingsManager = get(),
|
||||
plugin = get()
|
||||
pluginManager = get(),
|
||||
)
|
||||
}
|
||||
single<KvDbManager> { KvDbManagerImpl(androidContext()) }
|
||||
single {
|
||||
KVBackup(
|
||||
plugin = get(),
|
||||
pluginManager = get(),
|
||||
settingsManager = get(),
|
||||
inputFactory = get(),
|
||||
crypto = get(),
|
||||
dbManager = get()
|
||||
dbManager = get(),
|
||||
)
|
||||
}
|
||||
single {
|
||||
FullBackup(
|
||||
plugin = get(),
|
||||
pluginManager = get(),
|
||||
settingsManager = get(),
|
||||
inputFactory = get(),
|
||||
crypto = get()
|
||||
crypto = get(),
|
||||
)
|
||||
}
|
||||
single {
|
||||
BackupCoordinator(
|
||||
context = androidContext(),
|
||||
plugin = get(),
|
||||
pluginManager = get(),
|
||||
kv = get(),
|
||||
full = get(),
|
||||
clock = get(),
|
||||
packageService = get(),
|
||||
metadataManager = get(),
|
||||
settingsManager = get(),
|
||||
nm = get()
|
||||
nm = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import android.util.Log
|
|||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
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 libcore.io.IoUtils.closeQuietly
|
||||
import java.io.EOFException
|
||||
|
@ -39,12 +39,13 @@ private val TAG = FullBackup::class.java.simpleName
|
|||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class FullBackup(
|
||||
private val plugin: StoragePlugin,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val inputFactory: InputFactory,
|
||||
private val crypto: Crypto,
|
||||
) {
|
||||
|
||||
private val plugin get() = pluginManager.appPlugin
|
||||
private var state: FullBackupState? = null
|
||||
|
||||
fun hasState() = state != null
|
||||
|
|
|
@ -13,7 +13,7 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
|||
import com.stevesoltys.seedvault.crypto.Crypto
|
||||
import com.stevesoltys.seedvault.header.VERSION
|
||||
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 java.io.IOException
|
||||
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
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class KVBackup(
|
||||
private val plugin: StoragePlugin,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val inputFactory: InputFactory,
|
||||
private val crypto: Crypto,
|
||||
private val dbManager: KvDbManager,
|
||||
) {
|
||||
|
||||
private val plugin get() = pluginManager.appPlugin
|
||||
private var state: KVBackupState? = null
|
||||
|
||||
fun hasState() = state != null
|
||||
|
@ -138,7 +138,7 @@ internal class KVBackup(
|
|||
// K/V backups (typically starting with package manager metadata - @pm@)
|
||||
// are scheduled with JobInfo.Builder#setOverrideDeadline()
|
||||
// and thus do not respect backoff.
|
||||
settingsManager.canDoBackupNow()
|
||||
pluginManager.canDoBackupNow()
|
||||
} else {
|
||||
// all other packages always need upload
|
||||
true
|
||||
|
|
|
@ -18,6 +18,7 @@ import android.util.Log.INFO
|
|||
import androidx.annotation.WorkerThread
|
||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
|
||||
private val TAG = PackageService::class.java.simpleName
|
||||
|
@ -32,11 +33,12 @@ internal class PackageService(
|
|||
private val context: Context,
|
||||
private val backupManager: IBackupManager,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val plugin: StoragePlugin,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
) {
|
||||
|
||||
private val packageManager: PackageManager = context.packageManager
|
||||
private val myUserId = UserHandle.myUserId()
|
||||
private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin
|
||||
|
||||
val eligiblePackages: List<String>
|
||||
@WorkerThread
|
||||
|
|
|
@ -13,7 +13,7 @@ import com.stevesoltys.seedvault.header.MAX_SEGMENT_LENGTH
|
|||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import libcore.io.IoUtils.closeQuietly
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
|
@ -32,9 +32,8 @@ private class FullRestoreState(
|
|||
|
||||
private val TAG = FullRestore::class.java.simpleName
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class FullRestore(
|
||||
private val plugin: StoragePlugin,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
@Suppress("Deprecation")
|
||||
private val legacyPlugin: LegacyStoragePlugin,
|
||||
private val outputFactory: OutputFactory,
|
||||
|
@ -42,6 +41,7 @@ internal class FullRestore(
|
|||
private val crypto: Crypto,
|
||||
) {
|
||||
|
||||
private val plugin get() = pluginManager.appPlugin
|
||||
private var state: FullRestoreState? = null
|
||||
|
||||
fun hasState() = state != null
|
||||
|
|
|
@ -16,13 +16,12 @@ import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
|||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.getADForKV
|
||||
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.KvDbManager
|
||||
import libcore.io.IoUtils.closeQuietly
|
||||
import java.io.IOException
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.ArrayList
|
||||
import java.util.zip.GZIPInputStream
|
||||
import javax.crypto.AEADBadTagException
|
||||
|
||||
|
@ -39,9 +38,8 @@ private class KVRestoreState(
|
|||
|
||||
private val TAG = KVRestore::class.java.simpleName
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class KVRestore(
|
||||
private val plugin: StoragePlugin,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
@Suppress("Deprecation")
|
||||
private val legacyPlugin: LegacyStoragePlugin,
|
||||
private val outputFactory: OutputFactory,
|
||||
|
@ -50,6 +48,7 @@ internal class KVRestore(
|
|||
private val dbManager: KvDbManager,
|
||||
) {
|
||||
|
||||
private val plugin get() = pluginManager.appPlugin
|
||||
private var state: KVRestoreState? = null
|
||||
|
||||
/**
|
||||
|
|
|
@ -21,6 +21,7 @@ import com.stevesoltys.seedvault.metadata.DecryptionFailedException
|
|||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||
import com.stevesoltys.seedvault.metadata.MetadataReader
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.D2D_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
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class RestoreCoordinator(
|
||||
private val context: Context,
|
||||
private val crypto: Crypto,
|
||||
private val settingsManager: SettingsManager,
|
||||
private val metadataManager: MetadataManager,
|
||||
private val notificationManager: BackupNotificationManager,
|
||||
private val plugin: StoragePlugin,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
private val kv: KVRestore,
|
||||
private val full: FullRestore,
|
||||
private val metadataReader: MetadataReader,
|
||||
) {
|
||||
|
||||
private val plugin: StoragePlugin<*> get() = pluginManager.appPlugin
|
||||
private var state: RestoreCoordinatorState? = null
|
||||
private var backupMetadata: BackupMetadata? = null
|
||||
private val failedPackages = ArrayList<String>()
|
||||
|
@ -169,7 +170,7 @@ internal class RestoreCoordinator(
|
|||
// check if we even have a backup of that app
|
||||
if (metadataManager.getPackageMetadata(pmPackageName) != null) {
|
||||
// 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)
|
||||
notificationManager.onRemovableStorageNotAvailableForRestore(
|
||||
pmPackageName,
|
||||
|
@ -363,9 +364,8 @@ internal class RestoreCoordinator(
|
|||
|
||||
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 {
|
||||
val storage = settingsManager.getStorage() ?: return false
|
||||
val storage = pluginManager.storageProperties ?: return false
|
||||
return storage.isUnavailableUsb(context)
|
||||
}
|
||||
|
||||
|
|
|
@ -3,13 +3,14 @@ package com.stevesoltys.seedvault.ui
|
|||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.stevesoltys.seedvault.crypto.KeyManager
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.ui.storage.StorageViewModel
|
||||
|
||||
abstract class RequireProvisioningViewModel(
|
||||
protected val app: Application,
|
||||
protected val settingsManager: SettingsManager,
|
||||
protected val keyManager: KeyManager,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
) : AndroidViewModel(app) {
|
||||
|
||||
abstract val isRestoreOperation: Boolean
|
||||
|
@ -18,7 +19,7 @@ abstract class RequireProvisioningViewModel(
|
|||
internal val chooseBackupLocation: LiveEvent<Boolean> get() = mChooseBackupLocation
|
||||
internal fun chooseBackupLocation() = mChooseBackupLocation.setEvent(true)
|
||||
|
||||
internal fun validLocationIsSet() = StorageViewModel.validLocationIsSet(app, settingsManager)
|
||||
internal fun validLocationIsSet() = pluginManager.isValidAppPluginSet()
|
||||
|
||||
internal fun recoveryCodeIsSet() = keyManager.hasBackupKey()
|
||||
|
||||
|
|
|
@ -103,8 +103,7 @@ internal class RecoveryCodeViewModel(
|
|||
// TODO this code is almost identical to BackupStorageViewModel#onLocationSet(), unify?
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
// remove old storage snapshots and clear cache
|
||||
storageBackup.deleteAllSnapshots()
|
||||
storageBackup.clearCache()
|
||||
storageBackup.init()
|
||||
try {
|
||||
// initialize the new location
|
||||
if (backupManager.isBackupEnabled) backupInitializer.initialize({ }) {
|
||||
|
|
|
@ -3,11 +3,16 @@ package com.stevesoltys.seedvault.ui.storage
|
|||
import android.app.Application
|
||||
import android.app.backup.IBackupManager
|
||||
import android.app.job.JobInfo
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||
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.storage.StorageBackupJobService
|
||||
import com.stevesoltys.seedvault.transport.backup.BackupInitializer
|
||||
|
@ -26,14 +31,18 @@ internal class BackupStorageViewModel(
|
|||
private val backupManager: IBackupManager,
|
||||
private val backupInitializer: BackupInitializer,
|
||||
private val storageBackup: StorageBackup,
|
||||
safHandler: SafHandler,
|
||||
webDavHandler: WebDavHandler,
|
||||
settingsManager: SettingsManager,
|
||||
) : StorageViewModel(app, settingsManager) {
|
||||
storagePluginManager: StoragePluginManager,
|
||||
) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) {
|
||||
|
||||
override val isRestoreOperation = false
|
||||
|
||||
override fun onLocationSet(uri: Uri) {
|
||||
val isUsb = saveStorage(uri)
|
||||
if (isUsb) {
|
||||
override fun onSafUriSet(safStorage: SafStorage) {
|
||||
safHandler.save(safStorage)
|
||||
safHandler.setPlugin(safStorage)
|
||||
if (safStorage.isUsb) {
|
||||
// disable storage backup if new storage is on USB
|
||||
cancelBackupWorkers()
|
||||
} else {
|
||||
|
@ -41,13 +50,23 @@ internal class BackupStorageViewModel(
|
|||
// also to update the network requirement of the new storage
|
||||
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) {
|
||||
// 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 {
|
||||
// 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)
|
||||
if (backupManager.isBackupEnabled) {
|
||||
val onError = {
|
||||
|
@ -75,7 +94,7 @@ internal class BackupStorageViewModel(
|
|||
}
|
||||
|
||||
private fun scheduleBackupWorkers() {
|
||||
val storage = settingsManager.getStorage() ?: error("no storage available")
|
||||
val storage = storagePluginManager.storageProperties ?: error("no storage available")
|
||||
if (!storage.isUsb) {
|
||||
if (backupManager.isBackupEnabled) {
|
||||
AppBackupWorker.schedule(app, settingsManager, CANCEL_AND_REENQUEUE)
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
package com.stevesoltys.seedvault.ui.storage
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -16,26 +20,28 @@ private val TAG = RestoreStorageViewModel::class.java.simpleName
|
|||
|
||||
internal class RestoreStorageViewModel(
|
||||
private val app: Application,
|
||||
private val storagePlugin: StoragePlugin,
|
||||
safHandler: SafHandler,
|
||||
webDavHandler: WebDavHandler,
|
||||
settingsManager: SettingsManager,
|
||||
) : StorageViewModel(app, settingsManager) {
|
||||
storagePluginManager: StoragePluginManager,
|
||||
) : StorageViewModel(app, safHandler, webDavHandler, settingsManager, storagePluginManager) {
|
||||
|
||||
override val isRestoreOperation = true
|
||||
|
||||
override fun onLocationSet(uri: Uri) {
|
||||
override fun onSafUriSet(safStorage: SafStorage) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val storage = createStorage(uri)
|
||||
val hasBackup = try {
|
||||
storagePlugin.hasBackup(storage)
|
||||
safHandler.hasAppBackup(safStorage)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error reading URI: $uri", e)
|
||||
Log.e(TAG, "Error reading URI: ${safStorage.uri}", e)
|
||||
false
|
||||
}
|
||||
if (hasBackup) {
|
||||
saveStorage(storage)
|
||||
safHandler.save(safStorage)
|
||||
safHandler.setPlugin(safStorage)
|
||||
mLocationChecked.postEvent(LocationResult())
|
||||
} else {
|
||||
Log.w(TAG, "Location was rejected: $uri")
|
||||
Log.w(TAG, "Location was rejected: ${safStorage.uri}")
|
||||
|
||||
// notify the UI that the location was invalid
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,11 +9,11 @@ import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
|||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.plugins.saf.StorageRootResolver
|
||||
import com.stevesoltys.seedvault.ui.BackupActivity
|
||||
import com.stevesoltys.seedvault.ui.INTENT_EXTRA_IS_RESTORE
|
||||
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) {
|
||||
if (viewModel.isRestoreOperation) {
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package com.stevesoltys.seedvault.ui.storage
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
|
@ -10,6 +11,7 @@ import android.view.ViewGroup
|
|||
import android.widget.Button
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.activity.addCallback
|
||||
import androidx.fragment.app.Fragment
|
||||
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(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
package com.stevesoltys.seedvault.ui.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract.buildTreeDocumentUri
|
||||
import androidx.appcompat.content.res.AppCompatResources.getDrawable
|
||||
import com.stevesoltys.seedvault.R
|
||||
|
||||
internal sealed class StorageOption {
|
||||
abstract val id: String
|
||||
|
@ -46,3 +49,14 @@ internal sealed class StorageOption {
|
|||
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
|
||||
}
|
||||
|
|
|
@ -13,9 +13,11 @@ import android.provider.DocumentsContract.PROVIDER_INTERFACE
|
|||
import android.provider.DocumentsContract.buildRootsUri
|
||||
import android.util.Log
|
||||
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
|
||||
|
||||
private val TAG = StorageRootFetcher::class.java.simpleName
|
||||
private val TAG = StorageOptionFetcher::class.java.simpleName
|
||||
|
||||
const val AUTHORITY_STORAGE = "com.android.externalstorage.documents"
|
||||
const val ROOT_ID_DEVICE = "primary"
|
||||
|
@ -30,7 +32,7 @@ internal interface RemovableStorageListener {
|
|||
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 contentResolver = context.contentResolver
|
||||
|
@ -60,7 +62,9 @@ internal class StorageRootFetcher(private val context: Context, private val isRe
|
|||
internal fun getRemovableStorageListener() = listener
|
||||
|
||||
internal fun getStorageOptions(): List<StorageOption> {
|
||||
val roots = ArrayList<SafOption>()
|
||||
val roots = ArrayList<StorageOption>().apply {
|
||||
add(WebDavOption(context))
|
||||
}
|
||||
val intent = Intent(PROVIDER_INTERFACE)
|
||||
val providers = packageManager.queryIntentContentProviders(intent, 0)
|
||||
for (info in providers) {
|
|
@ -55,7 +55,7 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
|||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): 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)
|
||||
warningIcon = v.requireViewById(R.id.warningIcon)
|
||||
|
@ -115,11 +115,21 @@ internal class StorageOptionsFragment : Fragment(), StorageOptionClickedListener
|
|||
}
|
||||
|
||||
override fun onClick(storageOption: StorageOption) {
|
||||
if (storageOption is SafOption) {
|
||||
viewModel.onSafOptionChosen(storageOption)
|
||||
openDocumentTree.launch(storageOption.uri)
|
||||
} else {
|
||||
throw IllegalArgumentException("Non-SAF storage not yet supported")
|
||||
when (storageOption) {
|
||||
is SafOption -> {
|
||||
viewModel.onSafOptionChosen(storageOption)
|
||||
openDocumentTree.launch(storageOption.uri)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,32 +1,33 @@
|
|||
package com.stevesoltys.seedvault.ui.storage
|
||||
|
||||
import android.annotation.UiThread
|
||||
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.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.stevesoltys.seedvault.R
|
||||
import com.stevesoltys.seedvault.isMassStorage
|
||||
import com.stevesoltys.seedvault.permitDiskReads
|
||||
import com.stevesoltys.seedvault.settings.BackupManagerSettings
|
||||
import com.stevesoltys.seedvault.settings.FlashDrive
|
||||
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.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.Storage
|
||||
import com.stevesoltys.seedvault.ui.LiveEvent
|
||||
import com.stevesoltys.seedvault.ui.MutableLiveEvent
|
||||
import com.stevesoltys.seedvault.ui.storage.StorageOption.SafOption
|
||||
|
||||
private val TAG = StorageViewModel::class.java.simpleName
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal abstract class StorageViewModel(
|
||||
private val app: Application,
|
||||
protected val safHandler: SafHandler,
|
||||
protected val webdavHandler: WebDavHandler,
|
||||
protected val settingsManager: SettingsManager,
|
||||
protected val storagePluginManager: StoragePluginManager,
|
||||
) : AndroidViewModel(app), RemovableStorageListener {
|
||||
|
||||
private val mStorageOptions = MutableLiveData<List<StorageOption>>()
|
||||
|
@ -38,38 +39,29 @@ internal abstract class StorageViewModel(
|
|||
protected val mLocationChecked = MutableLiveEvent<LocationResult>()
|
||||
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
|
||||
|
||||
internal var isSetupWizard: Boolean = false
|
||||
internal val hasStorageSet: Boolean
|
||||
get() = settingsManager.getStorage() != null
|
||||
get() = storagePluginManager.storageProperties != null
|
||||
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() {
|
||||
if (storageRootFetcher.getRemovableStorageListener() == null) {
|
||||
storageRootFetcher.setRemovableStorageListener(this)
|
||||
if (storageOptionFetcher.getRemovableStorageListener() == null) {
|
||||
storageOptionFetcher.setRemovableStorageListener(this)
|
||||
}
|
||||
Thread {
|
||||
mStorageOptions.postValue(storageRootFetcher.getStorageOptions())
|
||||
mStorageOptions.postValue(storageOptionFetcher.getStorageOptions())
|
||||
}.start()
|
||||
}
|
||||
|
||||
override fun onStorageChanged() = loadStorageRoots()
|
||||
|
||||
/**
|
||||
* Remembers that the user chose SAF.
|
||||
* Usually followed by a call of [onUriPermissionResultReceived].
|
||||
*/
|
||||
fun onSafOptionChosen(option: SafOption) {
|
||||
safOption = option
|
||||
}
|
||||
|
@ -80,76 +72,40 @@ internal abstract class StorageViewModel(
|
|||
mLocationChecked.setEvent(LocationResult(msg))
|
||||
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
|
||||
mLocationSet.setEvent(true)
|
||||
|
||||
// persist permission to access backup folder across reboots
|
||||
val takeFlags = FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
app.contentResolver.takePersistableUriPermission(uri, takeFlags)
|
||||
|
||||
onLocationSet(uri)
|
||||
onSafUriSet(safStorage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the storage behind the given [Uri] (and saved [safOption]).
|
||||
*
|
||||
* @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)
|
||||
abstract fun onSafUriSet(safStorage: SafStorage)
|
||||
abstract fun onWebDavConfigSet(properties: WebDavProperties, plugin: WebDavStoragePlugin)
|
||||
|
||||
override fun onCleared() {
|
||||
storageRootFetcher.setRemovableStorageListener(null)
|
||||
storageOptionFetcher.setRemovableStorageListener(null)
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -30,7 +30,6 @@ import java.security.MessageDigest
|
|||
|
||||
private val TAG = ApkBackup::class.java.simpleName
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class ApkBackup(
|
||||
private val pm: PackageManager,
|
||||
private val crypto: Crypto,
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.metadata.MetadataManager
|
|||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||
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.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
|
@ -28,7 +29,7 @@ internal class ApkBackupManager(
|
|||
private val metadataManager: MetadataManager,
|
||||
private val packageService: PackageService,
|
||||
private val apkBackup: ApkBackup,
|
||||
private val plugin: StoragePlugin,
|
||||
private val pluginManager: StoragePluginManager,
|
||||
private val nm: BackupNotificationManager,
|
||||
) {
|
||||
|
||||
|
@ -50,7 +51,7 @@ internal class ApkBackupManager(
|
|||
keepTrying {
|
||||
// upload all local changes only at the end,
|
||||
// so we don't have to re-upload the metadata
|
||||
plugin.getMetadataOutputStream().use { outputStream ->
|
||||
pluginManager.appPlugin.getMetadataOutputStream().use { outputStream ->
|
||||
metadataManager.uploadMetadata(outputStream)
|
||||
}
|
||||
}
|
||||
|
@ -102,7 +103,7 @@ internal class ApkBackupManager(
|
|||
return try {
|
||||
apkBackup.backupApkIfNecessary(packageInfo) { name ->
|
||||
val token = settingsManager.getToken() ?: throw IOException("no current token")
|
||||
plugin.getOutputStream(token, name)
|
||||
pluginManager.appPlugin.getOutputStream(token, name)
|
||||
}?.let { packageMetadata ->
|
||||
metadataManager.onApkBackedUp(packageInfo, packageMetadata)
|
||||
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")
|
||||
return getOutputStream(t, FILE_BACKUP_METADATA)
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@ val workerModule = module {
|
|||
metadataManager = get(),
|
||||
packageService = get(),
|
||||
apkBackup = get(),
|
||||
plugin = get(),
|
||||
pluginManager = get(),
|
||||
nm = get()
|
||||
)
|
||||
}
|
||||
|
|
16
app/src/main/res/drawable/ic_cloud_circle.xml
Normal file
16
app/src/main/res/drawable/ic_cloud_circle.xml
Normal 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>
|
89
app/src/main/res/layout/fragment_webdav_config.xml
Normal file
89
app/src/main/res/layout/fragment_webdav_config.xml
Normal 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>
|
|
@ -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_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 -->
|
||||
<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>
|
||||
|
|
|
@ -8,13 +8,15 @@ import com.stevesoltys.seedvault.crypto.KeyManager
|
|||
import com.stevesoltys.seedvault.crypto.KeyManagerTestImpl
|
||||
import com.stevesoltys.seedvault.header.headerModule
|
||||
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.settings.SettingsManager
|
||||
import com.stevesoltys.seedvault.transport.backup.backupModule
|
||||
import com.stevesoltys.seedvault.transport.restore.restoreModule
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.KoinApplication
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
|
||||
class TestApp : App() {
|
||||
|
@ -31,19 +33,22 @@ class TestApp : App() {
|
|||
single { SettingsManager(this@TestApp) }
|
||||
}
|
||||
|
||||
override fun startKoin() = startKoin {
|
||||
androidContext(this@TestApp)
|
||||
modules(
|
||||
listOf(
|
||||
testCryptoModule,
|
||||
headerModule,
|
||||
metadataModule,
|
||||
documentsProviderModule, // storage plugin
|
||||
backupModule,
|
||||
restoreModule,
|
||||
installModule,
|
||||
appModule
|
||||
override fun startKoin(): KoinApplication {
|
||||
stopKoin()
|
||||
return startKoin {
|
||||
androidContext(this@TestApp)
|
||||
modules(
|
||||
listOf(
|
||||
testCryptoModule,
|
||||
headerModule,
|
||||
metadataModule,
|
||||
storagePluginModuleSaf, // storage plugin
|
||||
backupModule,
|
||||
restoreModule,
|
||||
installModule,
|
||||
appModule
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import io.mockk.mockkStatic
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class StoragePluginTest : BackupTest() {
|
||||
|
||||
private val storage = mockk<DocumentsStorage>()
|
||||
|
@ -39,7 +38,7 @@ internal class StoragePluginTest : BackupTest() {
|
|||
// get current set dir and for that the current token
|
||||
every { storage getProperty "currentToken" } 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
|
||||
// delete contents of current set dir
|
||||
coEvery { setDir.listFilesBlocking(context) } returns listOf(backupFile)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata
|
|||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.transport.TransportTest
|
||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||
|
@ -38,7 +39,6 @@ import java.nio.file.Path
|
|||
import kotlin.random.Random
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class ApkBackupRestoreTest : TransportTest() {
|
||||
|
||||
private val pm: PackageManager = mockk()
|
||||
|
@ -46,16 +46,17 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
|||
every { packageManager } returns pm
|
||||
}
|
||||
|
||||
private val storagePluginManager: StoragePluginManager = mockk()
|
||||
@Suppress("Deprecation")
|
||||
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
||||
private val storagePlugin: StoragePlugin = mockk()
|
||||
private val storagePlugin: StoragePlugin<*> = mockk()
|
||||
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
|
||||
private val apkInstaller: ApkInstaller = mockk()
|
||||
|
||||
private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager)
|
||||
private val apkRestore: ApkRestore = ApkRestore(
|
||||
context = strictContext,
|
||||
storagePlugin = storagePlugin,
|
||||
pluginManager = storagePluginManager,
|
||||
legacyStoragePlugin = legacyStoragePlugin,
|
||||
crypto = crypto,
|
||||
splitCompatChecker = splitCompatChecker,
|
||||
|
@ -90,6 +91,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
|||
|
||||
init {
|
||||
mockkStatic(PackageUtils::class)
|
||||
every { storagePluginManager.appPlugin } returns storagePlugin
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata
|
|||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||
|
@ -41,7 +42,6 @@ import java.io.IOException
|
|||
import java.nio.file.Path
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
@ExperimentalCoroutinesApi
|
||||
internal class ApkRestoreTest : TransportTest() {
|
||||
|
||||
|
@ -49,18 +49,19 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
private val strictContext: Context = mockk<Context>().apply {
|
||||
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 splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
|
||||
private val apkInstaller: ApkInstaller = mockk()
|
||||
|
||||
private val apkRestore: ApkRestore = ApkRestore(
|
||||
strictContext,
|
||||
storagePlugin,
|
||||
legacyStoragePlugin,
|
||||
crypto,
|
||||
splitCompatChecker,
|
||||
apkInstaller
|
||||
context = strictContext,
|
||||
pluginManager = storagePluginManager,
|
||||
legacyStoragePlugin = legacyStoragePlugin,
|
||||
crypto = crypto,
|
||||
splitCompatChecker = splitCompatChecker,
|
||||
apkInstaller = apkInstaller,
|
||||
)
|
||||
|
||||
private val icon: Drawable = mockk()
|
||||
|
@ -85,6 +86,8 @@ internal class ApkRestoreTest : TransportTest() {
|
|||
init {
|
||||
// as we don't do strict signature checking, we can use a relaxed mock
|
||||
packageInfo.signingInfo = mockk(relaxed = true)
|
||||
|
||||
every { storagePluginManager.appPlugin } returns storagePlugin
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -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) }
|
|
@ -17,6 +17,7 @@ import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
|
|||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
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.transport.backup.BackupCoordinator
|
||||
import com.stevesoltys.seedvault.transport.backup.FullBackup
|
||||
|
@ -46,7 +47,6 @@ import java.io.ByteArrayInputStream
|
|||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class CoordinatorIntegrationTest : TransportTest() {
|
||||
|
||||
private val inputFactory = mockk<InputFactory>()
|
||||
|
@ -58,18 +58,20 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
private val metadataReader = MetadataReaderImpl(cryptoImpl)
|
||||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
private val dbManager = TestKvDbManager()
|
||||
private val storagePluginManager: StoragePluginManager = mockk()
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
||||
private val backupPlugin = mockk<StoragePlugin>()
|
||||
private val backupPlugin = mockk<StoragePlugin<*>>()
|
||||
private val kvBackup =
|
||||
KVBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl, dbManager)
|
||||
private val fullBackup = FullBackup(backupPlugin, settingsManager, inputFactory, cryptoImpl)
|
||||
KVBackup(storagePluginManager, settingsManager, inputFactory, cryptoImpl, dbManager)
|
||||
private val fullBackup =
|
||||
FullBackup(storagePluginManager, settingsManager, inputFactory, cryptoImpl)
|
||||
private val apkBackup = mockk<ApkBackup>()
|
||||
private val packageService: PackageService = mockk()
|
||||
private val backup = BackupCoordinator(
|
||||
context,
|
||||
backupPlugin,
|
||||
storagePluginManager,
|
||||
kvBackup,
|
||||
fullBackup,
|
||||
clock,
|
||||
|
@ -80,7 +82,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
)
|
||||
|
||||
private val kvRestore = KVRestore(
|
||||
backupPlugin,
|
||||
storagePluginManager,
|
||||
legacyPlugin,
|
||||
outputFactory,
|
||||
headerReader,
|
||||
|
@ -88,14 +90,14 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
dbManager
|
||||
)
|
||||
private val fullRestore =
|
||||
FullRestore(backupPlugin, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||
FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||
private val restore = RestoreCoordinator(
|
||||
context,
|
||||
crypto,
|
||||
settingsManager,
|
||||
metadataManager,
|
||||
notificationManager,
|
||||
backupPlugin,
|
||||
storagePluginManager,
|
||||
kvRestore,
|
||||
fullRestore,
|
||||
metadataReader
|
||||
|
@ -113,6 +115,10 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
|||
// as we use real crypto, we need a real name for packageInfo
|
||||
private val realName = cryptoImpl.getNameForPackage(salt, packageInfo.packageName)
|
||||
|
||||
init {
|
||||
every { storagePluginManager.appPlugin } returns backupPlugin
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test key-value backup and restore with 2 records`() = runBlocking {
|
||||
val value = CapturingSlot<ByteArray>()
|
||||
|
|
|
@ -22,6 +22,7 @@ import com.stevesoltys.seedvault.settings.SettingsManager
|
|||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD
|
||||
import kotlin.random.Random
|
||||
|
@ -70,13 +71,24 @@ internal abstract class TransportTest {
|
|||
|
||||
init {
|
||||
mockkStatic(Log::class)
|
||||
val logTagSlot = slot<String>()
|
||||
val logMsgSlot = slot<String>()
|
||||
val logExSlot = slot<Throwable>()
|
||||
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.w(any(), ofType(String::class)) } returns 0
|
||||
every { Log.w(any(), ofType(String::class), 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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -16,8 +16,9 @@ import com.stevesoltys.seedvault.metadata.PackageMetadata
|
|||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||
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.settings.Storage
|
||||
import com.stevesoltys.seedvault.plugins.saf.SafStorage
|
||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||
import io.mockk.Runs
|
||||
|
@ -33,10 +34,9 @@ import java.io.IOException
|
|||
import java.io.OutputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class BackupCoordinatorTest : BackupTest() {
|
||||
|
||||
private val plugin = mockk<StoragePlugin>()
|
||||
private val pluginManager = mockk<StoragePluginManager>()
|
||||
private val kv = mockk<KVBackup>()
|
||||
private val full = mockk<FullBackup>()
|
||||
private val apkBackup = mockk<ApkBackup>()
|
||||
|
@ -44,27 +44,32 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
private val packageService = mockk<PackageService>()
|
||||
|
||||
private val backup = BackupCoordinator(
|
||||
context,
|
||||
plugin,
|
||||
kv,
|
||||
full,
|
||||
clock,
|
||||
packageService,
|
||||
metadataManager,
|
||||
settingsManager,
|
||||
notificationManager
|
||||
context = context,
|
||||
pluginManager = pluginManager,
|
||||
kv = kv,
|
||||
full = full,
|
||||
clock = clock,
|
||||
packageService = packageService,
|
||||
metadataManager = metadataManager,
|
||||
settingsManager = settingsManager,
|
||||
nm = notificationManager,
|
||||
)
|
||||
|
||||
private val plugin = mockk<StoragePlugin<*>>()
|
||||
private val metadataOutputStream = mockk<OutputStream>()
|
||||
private val fileDescriptor: ParcelFileDescriptor = mockk()
|
||||
private val packageMetadata: PackageMetadata = mockk()
|
||||
private val storage = Storage(
|
||||
uri = Uri.EMPTY,
|
||||
private val safStorage = SafStorage(
|
||||
config = Uri.EMPTY,
|
||||
name = getRandomString(),
|
||||
isUsb = false,
|
||||
requiresNetwork = false
|
||||
requiresNetwork = false,
|
||||
)
|
||||
|
||||
init {
|
||||
every { pluginManager.appPlugin } returns plugin
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device initialization succeeds and delegates to plugin`() = runBlocking {
|
||||
expectStartNewRestoreSet()
|
||||
|
@ -90,7 +95,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
expectStartNewRestoreSet()
|
||||
coEvery { plugin.initializeDevice() } throws IOException()
|
||||
every { metadataManager.requiresInit } returns maybeTrue
|
||||
every { settingsManager.canDoBackupNow() } returns !maybeTrue
|
||||
every { pluginManager.canDoBackupNow() } returns !maybeTrue
|
||||
every { notificationManager.onBackupError() } just Runs
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
|
||||
|
@ -109,7 +114,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
expectStartNewRestoreSet()
|
||||
coEvery { plugin.initializeDevice() } throws IOException()
|
||||
every { metadataManager.requiresInit } returns false
|
||||
every { settingsManager.canDoBackupNow() } returns false
|
||||
every { pluginManager.canDoBackupNow() } returns false
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, backup.initializeDevice())
|
||||
|
||||
|
@ -125,7 +130,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
fun `performIncrementalBackup of @pm@ causes re-init when legacy format`() = runBlocking {
|
||||
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
||||
|
||||
every { settingsManager.canDoBackupNow() } returns true
|
||||
every { pluginManager.canDoBackupNow() } returns true
|
||||
every { metadataManager.requiresInit } returns true
|
||||
|
||||
// start new restore set
|
||||
|
@ -224,7 +229,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
every { kv.getCurrentSize() } returns 42L
|
||||
|
||||
coEvery { kv.finishBackup() } returns TRANSPORT_OK
|
||||
every { settingsManager.canDoBackupNow() } returns false
|
||||
every { pluginManager.canDoBackupNow() } returns false
|
||||
|
||||
assertEquals(TRANSPORT_OK, backup.finishBackup())
|
||||
}
|
||||
|
@ -290,7 +295,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
)
|
||||
} 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 { metadataOutputStream.close() } just Runs
|
||||
|
||||
|
@ -340,7 +345,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
|||
)
|
||||
} 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 { metadataOutputStream.close() } just Runs
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.app.backup.BackupTransport.TRANSPORT_QUOTA_EXCEEDED
|
|||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
|
@ -21,16 +22,20 @@ import java.io.FileInputStream
|
|||
import java.io.IOException
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class FullBackupTest : BackupTest() {
|
||||
|
||||
private val plugin = mockk<StoragePlugin>()
|
||||
private val backup = FullBackup(plugin, settingsManager, inputFactory, crypto)
|
||||
private val storagePluginManager: StoragePluginManager = mockk()
|
||||
private val plugin = mockk<StoragePlugin<*>>()
|
||||
private val backup = FullBackup(storagePluginManager, settingsManager, inputFactory, crypto)
|
||||
|
||||
private val bytes = ByteArray(23).apply { Random.nextBytes(this) }
|
||||
private val inputStream = mockk<FileInputStream>()
|
||||
private val ad = getADForFull(VERSION, packageInfo.packageName)
|
||||
|
||||
init {
|
||||
every { storagePluginManager.appPlugin } returns plugin
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `has no initial state`() {
|
||||
assertFalse(backup.hasState())
|
||||
|
|
|
@ -13,6 +13,7 @@ import com.stevesoltys.seedvault.header.MAX_KEY_LENGTH_SIZE
|
|||
import com.stevesoltys.seedvault.header.VERSION
|
||||
import com.stevesoltys.seedvault.header.getADForKV
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
|
@ -30,22 +31,26 @@ import java.io.ByteArrayInputStream
|
|||
import java.io.IOException
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class KVBackupTest : BackupTest() {
|
||||
|
||||
private val plugin = mockk<StoragePlugin>()
|
||||
private val pluginManager = mockk<StoragePluginManager>()
|
||||
private val dataInput = mockk<BackupDataInput>()
|
||||
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 plugin = mockk<StoragePlugin<*>>()
|
||||
private val packageName = packageInfo.packageName
|
||||
private val key = getRandomString(MAX_KEY_LENGTH_SIZE)
|
||||
private val dataValue = Random.nextBytes(23)
|
||||
private val dbBytes = Random.nextBytes(42)
|
||||
private val inputStream = ByteArrayInputStream(dbBytes)
|
||||
|
||||
init {
|
||||
every { pluginManager.appPlugin } returns plugin
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `has no initial state`() {
|
||||
assertFalse(backup.hasState())
|
||||
|
@ -231,7 +236,7 @@ internal class KVBackupTest : BackupTest() {
|
|||
every { dbManager.existsDb(pmPackageInfo.packageName) } returns false
|
||||
every { crypto.getNameForPackage(salt, pmPackageInfo.packageName) } returns name
|
||||
every { dbManager.getDb(pmPackageInfo.packageName) } returns db
|
||||
every { settingsManager.canDoBackupNow() } returns false
|
||||
every { pluginManager.canDoBackupNow() } returns false
|
||||
every { db.put(key, dataValue) } just Runs
|
||||
getDataInput(listOf(true, false))
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import com.stevesoltys.seedvault.header.VersionHeader
|
|||
import com.stevesoltys.seedvault.header.getADForFull
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
|
@ -31,17 +32,27 @@ import java.io.IOException
|
|||
import java.security.GeneralSecurityException
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
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 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 outputStream = ByteArrayOutputStream()
|
||||
private val ad = getADForFull(VERSION, packageInfo.packageName)
|
||||
|
||||
init {
|
||||
every { storagePluginManager.appPlugin } returns plugin
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `has no initial state`() {
|
||||
assertFalse(restore.hasState())
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.header.VersionHeader
|
|||
import com.stevesoltys.seedvault.header.getADForKV
|
||||
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.KvDbManager
|
||||
import io.mockk.Runs
|
||||
|
@ -33,15 +34,22 @@ import java.security.GeneralSecurityException
|
|||
import java.util.zip.GZIPOutputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
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 dbManager = mockk<KvDbManager>()
|
||||
private val output = mockk<BackupDataOutput>()
|
||||
private val restore =
|
||||
KVRestore(plugin, legacyPlugin, outputFactory, headerReader, crypto, dbManager)
|
||||
private val restore = KVRestore(
|
||||
pluginManager = storagePluginManager,
|
||||
legacyPlugin = legacyPlugin,
|
||||
outputFactory = outputFactory,
|
||||
headerReader = headerReader,
|
||||
crypto = crypto,
|
||||
dbManager = dbManager,
|
||||
)
|
||||
|
||||
private val db = mockk<KVDb>()
|
||||
private val ad = getADForKV(VERSION, packageInfo.packageName)
|
||||
|
@ -60,6 +68,8 @@ internal class KVRestoreTest : RestoreTest() {
|
|||
init {
|
||||
// for InputStream#readBytes()
|
||||
mockkStatic("kotlin.io.ByteStreamsKt")
|
||||
|
||||
every { storagePluginManager.appPlugin } returns plugin
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -180,7 +190,6 @@ internal class KVRestoreTest : RestoreTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
@Suppress("Deprecation")
|
||||
fun `v0 listing records throws`() = runBlocking {
|
||||
restore.initializeState(0x00, token, name, packageInfo)
|
||||
|
||||
|
|
|
@ -16,7 +16,8 @@ import com.stevesoltys.seedvault.metadata.MetadataReader
|
|||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||
import com.stevesoltys.seedvault.plugins.EncryptedMetadata
|
||||
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.ui.notification.BackupNotificationManager
|
||||
import io.mockk.Runs
|
||||
|
@ -35,29 +36,29 @@ import java.io.IOException
|
|||
import java.io.InputStream
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class RestoreCoordinatorTest : TransportTest() {
|
||||
|
||||
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 full = mockk<FullRestore>()
|
||||
private val metadataReader = mockk<MetadataReader>()
|
||||
|
||||
private val restore = RestoreCoordinator(
|
||||
context,
|
||||
crypto,
|
||||
settingsManager,
|
||||
metadataManager,
|
||||
notificationManager,
|
||||
plugin,
|
||||
kv,
|
||||
full,
|
||||
metadataReader
|
||||
context = context,
|
||||
crypto = crypto,
|
||||
settingsManager = settingsManager,
|
||||
metadataManager = metadataManager,
|
||||
notificationManager = notificationManager,
|
||||
pluginManager = storagePluginManager,
|
||||
kv = kv,
|
||||
full = full,
|
||||
metadataReader = metadataReader,
|
||||
)
|
||||
|
||||
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 packageInfoArray = arrayOf(packageInfo)
|
||||
private val packageInfoArray2 = arrayOf(packageInfo, packageInfo2)
|
||||
|
@ -71,6 +72,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
init {
|
||||
metadata.packageMetadataMap[packageInfo2.packageName] =
|
||||
PackageMetadata(backupType = BackupType.FULL)
|
||||
|
||||
every { storagePluginManager.appPlugin } returns plugin
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -164,10 +167,10 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
@Test
|
||||
fun `startRestore() optimized auto-restore with removed storage shows notification`() =
|
||||
runBlocking {
|
||||
every { settingsManager.getStorage() } returns storage
|
||||
every { storage.isUnavailableUsb(context) } returns true
|
||||
every { storagePluginManager.storageProperties } returns safStorage
|
||||
every { safStorage.isUnavailableUsb(context) } returns true
|
||||
every { metadataManager.getPackageMetadata(packageName) } returns PackageMetadata(42L)
|
||||
every { storage.name } returns storageName
|
||||
every { safStorage.name } returns storageName
|
||||
every {
|
||||
notificationManager.onRemovableStorageNotAvailableForRestore(
|
||||
packageName,
|
||||
|
@ -188,8 +191,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
@Test
|
||||
fun `startRestore() optimized auto-restore with available storage shows no notification`() =
|
||||
runBlocking {
|
||||
every { settingsManager.getStorage() } returns storage
|
||||
every { storage.isUnavailableUsb(context) } returns false
|
||||
every { storagePluginManager.storageProperties } returns safStorage
|
||||
every { safStorage.isUnavailableUsb(context) } returns false
|
||||
|
||||
restore.beforeStartRestore(metadata)
|
||||
assertEquals(TRANSPORT_OK, restore.startRestore(token, pmPackageInfoArray))
|
||||
|
@ -204,8 +207,8 @@ internal class RestoreCoordinatorTest : TransportTest() {
|
|||
|
||||
@Test
|
||||
fun `startRestore() with removed storage shows no notification`() = runBlocking {
|
||||
every { settingsManager.getStorage() } returns storage
|
||||
every { storage.isUnavailableUsb(context) } returns true
|
||||
every { storagePluginManager.storageProperties } returns safStorage
|
||||
every { safStorage.isUnavailableUsb(context) } returns true
|
||||
every { metadataManager.getPackageMetadata(packageName) } returns null
|
||||
|
||||
assertEquals(TRANSPORT_ERROR, restore.startRestore(token, pmPackageInfoArray))
|
||||
|
|
|
@ -15,6 +15,7 @@ import com.stevesoltys.seedvault.header.HeaderReaderImpl
|
|||
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
|
||||
import com.stevesoltys.seedvault.plugins.LegacyStoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePlugin
|
||||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.toByteArrayFromHex
|
||||
import com.stevesoltys.seedvault.transport.TransportTest
|
||||
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.
|
||||
*/
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class RestoreV0IntegrationTest : TransportTest() {
|
||||
|
||||
private val outputFactory = mockk<OutputFactory>()
|
||||
|
@ -49,30 +49,31 @@ internal class RestoreV0IntegrationTest : TransportTest() {
|
|||
private val dbManager = mockk<KvDbManager>()
|
||||
private val metadataReader = MetadataReaderImpl(cryptoImpl)
|
||||
private val notificationManager = mockk<BackupNotificationManager>()
|
||||
private val storagePluginManager: StoragePluginManager = mockk()
|
||||
|
||||
@Suppress("Deprecation")
|
||||
private val legacyPlugin = mockk<LegacyStoragePlugin>()
|
||||
private val backupPlugin = mockk<StoragePlugin>()
|
||||
private val backupPlugin = mockk<StoragePlugin<*>>()
|
||||
private val kvRestore = KVRestore(
|
||||
backupPlugin,
|
||||
legacyPlugin,
|
||||
outputFactory,
|
||||
headerReader,
|
||||
cryptoImpl,
|
||||
dbManager
|
||||
pluginManager = storagePluginManager,
|
||||
legacyPlugin = legacyPlugin,
|
||||
outputFactory = outputFactory,
|
||||
headerReader = headerReader,
|
||||
crypto = cryptoImpl,
|
||||
dbManager = dbManager,
|
||||
)
|
||||
private val fullRestore =
|
||||
FullRestore(backupPlugin, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||
FullRestore(storagePluginManager, legacyPlugin, outputFactory, headerReader, cryptoImpl)
|
||||
private val restore = RestoreCoordinator(
|
||||
context,
|
||||
crypto,
|
||||
settingsManager,
|
||||
metadataManager,
|
||||
notificationManager,
|
||||
backupPlugin,
|
||||
kvRestore,
|
||||
fullRestore,
|
||||
metadataReader
|
||||
context = context,
|
||||
crypto = crypto,
|
||||
settingsManager = settingsManager,
|
||||
metadataManager = metadataManager,
|
||||
notificationManager = notificationManager,
|
||||
pluginManager = storagePluginManager,
|
||||
kv = kvRestore,
|
||||
full = fullRestore,
|
||||
metadataReader = metadataReader,
|
||||
).apply { beforeStartRestore(metadata.copy(version = 0x00)) }
|
||||
|
||||
private val fileDescriptor = mockk<ParcelFileDescriptor>(relaxed = true)
|
||||
|
@ -116,6 +117,10 @@ internal class RestoreV0IntegrationTest : TransportTest() {
|
|||
private val key2 = "RestoreKey2"
|
||||
private val key264 = key2.encodeBase64()
|
||||
|
||||
init {
|
||||
every { storagePluginManager.appPlugin } returns backupPlugin
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test key-value backup and restore with 2 records`() = runBlocking {
|
||||
val encryptedAppData = ("00002A2C701AA7C91D1286E265D29169B25C41E6D0" +
|
||||
|
|
|
@ -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.WAS_STOPPED
|
||||
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.transport.TransportTest
|
||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||
|
@ -32,7 +33,8 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
|
||||
private val packageService: PackageService = 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 apkBackupManager = ApkBackupManager(
|
||||
|
@ -41,13 +43,17 @@ internal class ApkBackupManagerTest : TransportTest() {
|
|||
metadataManager = metadataManager,
|
||||
packageService = packageService,
|
||||
apkBackup = apkBackup,
|
||||
plugin = plugin,
|
||||
pluginManager = storagePluginManager,
|
||||
nm = nm,
|
||||
)
|
||||
|
||||
private val metadataOutputStream = mockk<OutputStream>()
|
||||
private val packageMetadata: PackageMetadata = mockk()
|
||||
|
||||
init {
|
||||
every { storagePluginManager.appPlugin } returns plugin
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Package state of app that is not stopped gets recorded as not-allowed`() = runBlocking {
|
||||
every { nm.onAppsNotBackedUp() } just Runs
|
||||
|
|
|
@ -38,7 +38,6 @@ import java.io.OutputStream
|
|||
import java.nio.file.Path
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class ApkBackupTest : BackupTest() {
|
||||
|
||||
private val pm: PackageManager = mockk()
|
||||
|
|
17
libs/dav4jvm/Android.bp
Normal file
17
libs/dav4jvm/Android.bp
Normal 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",
|
||||
}
|
BIN
libs/dav4jvm/dav4jvm-2.2.1.jar
Normal file
BIN
libs/dav4jvm/dav4jvm-2.2.1.jar
Normal file
Binary file not shown.
BIN
libs/dav4jvm/okhttp-4.11.0.jar
Normal file
BIN
libs/dav4jvm/okhttp-4.11.0.jar
Normal file
Binary file not shown.
BIN
libs/dav4jvm/okio-jvm-3.7.0.jar
Normal file
BIN
libs/dav4jvm/okio-jvm-3.7.0.jar
Normal file
Binary file not shown.
|
@ -18,7 +18,7 @@ class App : Application() {
|
|||
val settingsManager: SettingsManager by lazy { SettingsManager(applicationContext) }
|
||||
val storageBackup: StorageBackup by lazy {
|
||||
val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() }
|
||||
StorageBackup(this, plugin)
|
||||
StorageBackup(this, { plugin })
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
|
|
|
@ -32,11 +32,11 @@ import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
|
|||
|
||||
private val logEmptyState = """
|
||||
Press the button below to simulate a backup. Your files won't be changed and not uploaded anywhere. This is just to test code for a future real backup.
|
||||
|
||||
|
||||
Please come back to this app from time to time and run a backup again to see if it correctly identifies files that were added/changed.
|
||||
|
||||
|
||||
Note that after updating this app, it might need to re-backup all files again.
|
||||
|
||||
|
||||
Thanks for testing!
|
||||
""".trimIndent()
|
||||
private const val TAG = "MainViewModel"
|
||||
|
@ -98,8 +98,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
|
|||
fun setBackupLocation(uri: Uri?) {
|
||||
if (uri != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
storageBackup.deleteAllSnapshots()
|
||||
storageBackup.clearCache()
|
||||
storageBackup.init()
|
||||
}
|
||||
}
|
||||
settingsManager.setBackupLocation(uri)
|
||||
|
|
|
@ -42,12 +42,12 @@ class TestSafStoragePlugin(
|
|||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
override fun getChunkOutputStream(chunkId: String): OutputStream {
|
||||
override suspend fun getChunkOutputStream(chunkId: String): OutputStream {
|
||||
if (getLocationUri() == null) return nullStream
|
||||
return super.getChunkOutputStream(chunkId)
|
||||
}
|
||||
|
||||
override fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
||||
override suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream {
|
||||
if (root == null) return nullStream
|
||||
return super.getBackupSnapshotOutputStream(timestamp)
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ public sealed class SnapshotResult {
|
|||
public data class StoredSnapshot(
|
||||
/**
|
||||
* The unique ID of the current device/user combination chosen by the [StoragePlugin].
|
||||
* It may include an '.sv' extension.
|
||||
*/
|
||||
public val userId: String,
|
||||
/**
|
||||
|
|
|
@ -36,10 +36,9 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|||
|
||||
private const val TAG = "StorageBackup"
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
public class StorageBackup(
|
||||
private val context: Context,
|
||||
private val plugin: StoragePlugin,
|
||||
private val pluginGetter: () -> StoragePlugin,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
|
@ -50,18 +49,18 @@ public class StorageBackup(
|
|||
private val uriStore by lazy { db.getUriStore() }
|
||||
|
||||
private val mediaScanner by lazy { MediaScanner(context) }
|
||||
private val snapshotRetriever = SnapshotRetriever(plugin)
|
||||
private val chunksCacheRepopulater = ChunksCacheRepopulater(db, plugin, snapshotRetriever)
|
||||
private val snapshotRetriever = SnapshotRetriever(pluginGetter)
|
||||
private val chunksCacheRepopulater = ChunksCacheRepopulater(db, pluginGetter, snapshotRetriever)
|
||||
private val backup by lazy {
|
||||
val documentScanner = DocumentScanner(context)
|
||||
val fileScanner = FileScanner(uriStore, mediaScanner, documentScanner)
|
||||
Backup(context, db, fileScanner, plugin, chunksCacheRepopulater)
|
||||
Backup(context, db, fileScanner, pluginGetter, chunksCacheRepopulater)
|
||||
}
|
||||
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 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 restoreRunning = AtomicBoolean(false)
|
||||
|
@ -104,6 +103,16 @@ public class StorageBackup(
|
|||
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
|
||||
* (potentially encrypted with an old key) laying around.
|
||||
|
@ -113,9 +122,9 @@ public class StorageBackup(
|
|||
*/
|
||||
public suspend fun deleteAllSnapshots(): Unit = withContext(dispatcher) {
|
||||
try {
|
||||
plugin.getCurrentBackupSnapshots().forEach {
|
||||
pluginGetter().getCurrentBackupSnapshots().forEach {
|
||||
try {
|
||||
plugin.deleteBackupSnapshot(it)
|
||||
pluginGetter().deleteBackupSnapshot(it)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error deleting snapshot $it", e)
|
||||
}
|
||||
|
|
|
@ -13,6 +13,13 @@ import javax.crypto.SecretKey
|
|||
|
||||
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.
|
||||
* Plugins should use this opportunity
|
||||
|
@ -32,10 +39,10 @@ public interface StoragePlugin {
|
|||
public fun hasMasterKey(): Boolean
|
||||
|
||||
@Throws(IOException::class)
|
||||
public fun getChunkOutputStream(chunkId: String): OutputStream
|
||||
public suspend fun getChunkOutputStream(chunkId: String): OutputStream
|
||||
|
||||
@Throws(IOException::class)
|
||||
public fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream
|
||||
public suspend fun getBackupSnapshotOutputStream(timestamp: Long): OutputStream
|
||||
|
||||
/* Restore */
|
||||
|
||||
|
|
|
@ -35,12 +35,11 @@ internal class BackupResult(
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class Backup(
|
||||
private val context: Context,
|
||||
private val db: Db,
|
||||
private val fileScanner: FileScanner,
|
||||
private val storagePlugin: StoragePlugin,
|
||||
private val storagePluginGetter: () -> StoragePlugin,
|
||||
private val cacheRepopulater: ChunksCacheRepopulater,
|
||||
chunkSizeMax: Int = CHUNK_SIZE_MAX,
|
||||
private val streamCrypto: StreamCrypto = StreamCrypto,
|
||||
|
@ -54,6 +53,7 @@ internal class Backup(
|
|||
}
|
||||
|
||||
private val contentResolver = context.contentResolver
|
||||
private val storagePlugin get() = storagePluginGetter()
|
||||
private val filesCache = db.getFilesCache()
|
||||
private val chunksCache = db.getChunksCache()
|
||||
|
||||
|
|
|
@ -37,7 +37,7 @@ internal class ChunkWriter(
|
|||
private val buffer = ByteArray(bufferSize)
|
||||
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
fun writeChunk(
|
||||
suspend fun writeChunk(
|
||||
inputStream: InputStream,
|
||||
chunks: List<Chunk>,
|
||||
missingChunkIds: List<String>,
|
||||
|
@ -67,7 +67,7 @@ internal class ChunkWriter(
|
|||
}
|
||||
|
||||
@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 ->
|
||||
chunkStream.write(VERSION.toInt())
|
||||
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.
|
||||
*/
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
fun writeZipChunk(
|
||||
suspend fun writeZipChunk(
|
||||
chunk: ZipChunk,
|
||||
zip: ByteArrayOutputStream,
|
||||
missingChunkIds: List<String>,
|
||||
|
|
|
@ -19,10 +19,9 @@ import kotlin.time.toDuration
|
|||
|
||||
private const val TAG = "ChunksCacheRepopulater"
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
internal class ChunksCacheRepopulater(
|
||||
private val db: Db,
|
||||
private val storagePlugin: StoragePlugin,
|
||||
private val storagePlugin: () -> StoragePlugin,
|
||||
private val snapshotRetriever: SnapshotRetriever,
|
||||
) {
|
||||
|
||||
|
@ -43,7 +42,7 @@ internal class ChunksCacheRepopulater(
|
|||
availableChunkIds: HashSet<String>,
|
||||
) {
|
||||
val start = System.currentTimeMillis()
|
||||
val snapshots = storagePlugin.getCurrentBackupSnapshots().mapNotNull { storedSnapshot ->
|
||||
val snapshots = storagePlugin().getCurrentBackupSnapshots().mapNotNull { storedSnapshot ->
|
||||
try {
|
||||
snapshotRetriever.getSnapshot(streamKey, storedSnapshot)
|
||||
} catch (e: GeneralSecurityException) {
|
||||
|
@ -63,7 +62,7 @@ internal class ChunksCacheRepopulater(
|
|||
// delete chunks that are not references by any snapshot anymore
|
||||
val chunksToDelete = availableChunkIds.subtract(cachedChunks.map { it.id })
|
||||
val deletionDuration = measure {
|
||||
storagePlugin.deleteChunks(chunksToDelete.toList())
|
||||
storagePlugin().deleteChunks(chunksToDelete.toList())
|
||||
}
|
||||
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
Loading…
Reference in a new issue